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.

Undisposed subscriptions on long-lived stores Left: a module-level Zustand store keeps a Set of listeners; alongside the hook-managed listener of a mounted component are three listeners added with store.subscribe by charts that were removed, each retaining its chart and DOM. Right: a MobX observable document store has reactions from three closed documents that were never disposed; each keeps running and retains its document state. Zustand store.listeners (Set) useStore hook · mounted component store.subscribe · removed chart #1 store.subscribe · removed chart #2 store.subscribe · removed chart #3 MobX observable docStore observer() component · auto-disposed reaction · closed doc A (still running) reaction · closed doc B (still running) when · predicate never true

Step-by-Step Fix

  1. Search for vanilla subscription APIs. Find .subscribe( on Zustand stores and autorun(, reaction(, when(, observe(, intercept( in MobX code, plus keepAlive: true computeds. Verification: you have a list of subscription sites outside hooks and observer.
  2. 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.
  3. Prefer the React integrations. Replace store.subscribe in components with useStore(selector) (or useStore(selector, shallow)), and wrap MobX-consuming components in observer. Verification: components no longer create vanilla subscriptions.
  4. 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).
  5. 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.
  6. 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.
Store listeners across 20 chart mounts Before the fix, each chart mount adds a store.subscribe listener that is never removed, so the listener count rises from 4 to 24. After returning the unsubscribe function from the effect, the count stays at 5 while one chart is mounted. 24 0 subscribe without unsubscribe unsubscribe returned from the effect chart mount/unmount cycles (0 → 20)

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.

Confirming subscriptions return to baseline Record the store’s listener and reaction counts, run repeated open and close cycles, and confirm the counts return to baseline. Then check heap snapshots for state belonging to closed features. MobX trace() shows which reactions still observe an observable. Baseline counts listeners, reactions Open/close ×10 feature mounts and unmounts Counts equal? back to baseline Snapshot no closed-feature state still higher: use MobX trace() to see which reaction still observes

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.