Zustand and MobX Subscription Leaks
Hooks and observer components clean up after themselves, yet the app still leaks: a chart helper subscribes to a Zustand store with store.subscribe, a MobX reaction syncs state to local storage per open document, and each is created again on every mount. This guide from State Management and Client Cache Memory Leaks, in Framework-Specific Memory Optimization, shows where lightweight state libraries leave subscription lifetime to you, how to find undisposed subscriptions, and how to keep the stores themselves from growing without bound.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Listener count on a Zustand store grows per mount | store.subscribe() called in an effect without returning the unsubscribe |
Return the unsubscribe from the effect | One listener per mounted consumer |
| MobX reactions keep running after views close | autorun/reaction/when disposers never called |
Store disposers; call them on teardown | Reactions stop; captured state released |
| Unmounted component retained via store listener | Listener closure captured component state or setters | Unsubscribe; use the library’s React hooks | Component collectable |
| Store holds every entity ever loaded | Normalised maps only ever gain keys | Evict entities on route exit or with an LRU | Store size bounded |
keepAlive computed values never released |
MobX computed kept alive regardless of observers | Remove keepAlive or scope the computed |
Cached results released |
Root Cause: Vanilla Subscriptions Have No Owner
Both libraries make the common path safe. Zustand’s useStore(selector) hook subscribes when a component mounts and unsubscribes when it unmounts; MobX’s observer() wrapper tracks which observables a component read and disposes its reaction on unmount. Memory problems appear when code steps off that path and uses the vanilla subscription APIs, which return a disposer and trust you to call it.
In Zustand, store.subscribe(listener) (and subscribeWithSelector’s store.subscribe(selector, listener)) adds the listener to a Set held by the store and returns an unsubscribe function. Stores are usually module-level singletons, so every listener added and never removed lives for the whole session — along with whatever its closure captured: component setters, props, chart instances, DOM refs. This is the same retention chain as why unmounted React components stay in heap snapshots, with the store’s listener set as the root.
In MobX, autorun(fn), reaction(expr, effect) and when(predicate, effect) create reactions that observe the observables they read and return a disposer. Until disposed, the reaction stays subscribed to its observables — which keeps the reaction and its closure reachable from the observable state — and continues running on every change. A reaction created per opened document, per chart or per websocket without being disposed accumulates one live reaction per creation. when disposes itself after the predicate becomes true, but if it never does, it lives forever. Computed values created with keepAlive: true stay cached even with no observers.
Separately, the stores themselves grow. Normalised entity maps (entitiesById) in either library typically only gain keys: every record the user opens is added, none removed. That is correct for data that must stay, and a slow leak for data that only mattered on one page — the same pattern discussed for Redux in Redux store memory growth and normalized state.
Step-by-Step Fix
- Search for vanilla subscription APIs. Find
.subscribe(on Zustand stores andautorun(,reaction(,when(,observe(,intercept(in MobX code, pluskeepAlive: truecomputeds. Verification: you have a list of subscription sites outside hooks andobserver. - Check each for a disposer call. Confirm the returned function is stored and called on teardown (effect cleanup, class
dispose(), document close). Verification: every site has a matching disposal path. - Prefer the React integrations. Replace
store.subscribein components withuseStore(selector)(oruseStore(selector, shallow)), and wrap MobX-consuming components inobserver. Verification: components no longer create vanilla subscriptions. - Own reactions in objects with a lifecycle. For per-document or per-feature logic, create a class that starts its reactions in the constructor, stores disposers, and disposes them in
dispose(), called when the feature closes. Verification: closing a document stops its reactions (a log inside confirms). - Bound entity maps. Remove entities when their page closes, or keep them in an LRU keyed by ID. Verification: store size does not grow with the number of records opened.
- Verify with repeated cycles. Open and close the feature twenty times; count Zustand listeners (see snippet) or MobX reactions, and take a heap snapshot. Verification: counts return to baseline and no feature state remains.
Command and Code Reference
Use case: a Zustand subscription driving an imperative chart, with cleanup.
import { useEffect, useRef } from 'react';
import { useMetrics } from './stores/metrics'; // create((set) => ...) store
function LiveChart() {
const el = useRef(null);
useEffect(() => {
const chart = createChart(el.current);
// Vanilla subscribe: fine for imperative updates, but it MUST be undone
const unsubscribe = useMetrics.subscribe((state) => chart.update(state.series));
return () => {
unsubscribe(); // remove listener from the store's Set
chart.destroy(); // and release the chart itself
};
}, []);
return <div ref={el} />;
}
Use case: MobX reactions owned by a disposable feature object.
import { reaction, autorun } from 'mobx';
export class DocumentSession {
#disposers = [];
constructor(doc, storage) {
this.#disposers.push(
reaction(() => doc.title, (title) => storage.saveTitle(doc.id, title), { delay: 500 }),
autorun(() => { document.title = `${doc.title} — Editor`; }),
);
}
dispose() {
this.#disposers.forEach((d) => d()); // stop every reaction
this.#disposers = [];
}
}
// Owner: create on open, dispose on close
const sessions = new Map();
export function openDoc(doc) { sessions.set(doc.id, new DocumentSession(doc, storage)); }
export function closeDoc(id) { sessions.get(id)?.dispose(); sessions.delete(id); }
Use case: count Zustand listeners in development. Wrapping subscribe exposes the listener count without relying on internals.
export function withListenerCount(store) {
let count = 0;
const original = store.subscribe;
store.subscribe = (...args) => {
count++;
const unsub = original(...args);
return () => { count--; unsub(); };
};
store.listenerCount = () => count; // inspect in the console or tests
return store;
}
Verification and Regression Prevention
After fixing, repeated open/close or mount/unmount cycles should leave listener and reaction counts at baseline, closed features’ state should be absent from heap snapshots, and store sizes should track what is on screen rather than everything ever loaded. MobX’s trace() and the mobx developer tools help confirm which reactions are still observing an observable.
For prevention, reserve vanilla subscriptions for imperative integrations and always wrap them in an effect or a disposable owner; lint for .subscribe( results that are not stored, and for MobX reaction creators whose return values are discarded. Add an end-to-end test that cycles the feature and asserts on the development listener count. Server-state libraries have their own cache lifetimes, discussed in TanStack Query cache garbage collection settings.
Edge Cases and Gotchas
Stores created per component
Creating a Zustand store inside a component (for per-instance state) is fine if it is created once per instance (in useState or useRef) and nothing outside keeps it. Creating it at module level keyed by ID, without removal, keeps every instance’s store forever.
Selectors returning new objects
A selector that returns a new object or array every time causes re-renders on every state change unless combined with a shallow equality function. That is a performance issue that also increases allocation churn.
MobX observable arrays of large objects
Making large datasets deeply observable wraps every nested object in observable proxies or administration objects. Use observable.ref or observable.shallow for large, immutable data to reduce memory.
Persist middleware
Persistence middleware serialises state on every change. Large stores with frequent updates allocate big strings repeatedly; throttle persistence or persist a subset.
Frequently Asked Questions
Does Zustand unsubscribe automatically?
Its React hooks do: useStore(selector) subscribes on mount and unsubscribes on unmount. The vanilla store.subscribe API does not; it returns an unsubscribe function that you must call, typically from an effect’s cleanup.
How do I dispose MobX reactions?
autorun, reaction and when return a disposer function. Call it when the reaction is no longer needed — in a component’s effect cleanup, or in a dispose() method of the object that owns the reaction. observer components dispose their own reactions automatically.
Can a store itself leak memory?
Yes, if it keeps accumulating data: entity maps that only gain keys, logs or histories that grow, caches without bounds. Subscriptions are one leak source; unbounded state is the other. Remove data when its page closes or bound it.
Why is my component still in memory after unmount with Zustand?
Usually because a listener registered with store.subscribe captured its state setter or props and was never removed. The store’s listener set then retains the closure, and through it the component. Return the unsubscribe from the effect.
Is useSyncExternalStore safer for custom stores?
Yes. It handles subscription and unsubscription with the component lifecycle, and it is what modern store hooks use internally. Use it when you integrate a custom store with React.
Related
- State Management and Client Cache Memory Leaks — the parent topic
- RTK Query keepUnusedDataFor and Cache Lifetime — server-state cache lifetimes in Redux Toolkit
- Vue Event Bus Listener Leaks — the same subscription pattern in Vue
- Framework-Specific Memory Optimization — the section overview