Skip to content
ALL WRITING

// build log

Reactivity is easy until someone calls .clear()

Building QuantaJS taught me that a reactive system is defined by its worst case, not its demo. Here is the whole dependency engine, and the collection bug that took the longest to admit was a design flaw.

11 min read1,698 words

Every reactive state library demo is the same. A counter, an increment button, a number that changes on screen. It takes eleven lines and it always works.

code
import { createStore } from "@quantajs/core";
 
const counter = createStore("counter", {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++;
    },
  },
});
 
counter.increment();
console.log(counter.count); // 1

I shipped that demo for QuantaJS early and felt good about it. Then someone put a Map in their state, called .clear(), and a component kept rendering data that no longer existed.

That bug is the reason 2.0.0 exists. This post is what I learned taking it apart — the actual dependency engine, why collections break the assumptions a Proxy-based system quietly makes, and which of my fixes were fixes versus which were admissions.

The engine, in full

The whole tracking system is three ideas. It is worth seeing them together before anything goes wrong, because every bug later is one of these three behaving exactly as written and still being incorrect.

A dependency is a set of callbacks. One per reactive property.

code
export class Dependency {
  private subscribers: Set<EffectFunction>;
 
  depend(callback: EffectFunction | null) {
    if (callback) this.subscribers.add(callback);
  }
 
  notify(): void {
    // Snapshot BEFORE iterating. Subscribers may remove and re-add
    // themselves mid-run, and iterating the live Set would loop forever.
    const snapshot = [...this.subscribers];
    for (const subscriber of snapshot) {
      if (subscriber.active === false) continue;
      subscriber();
    }
  }
}

That snapshot comment is load-bearing. An effect re-subscribes itself every time it runs, so notifying from the live Set is an infinite loop — per spec, not by accident. It cost me an afternoon and a hung tab.

A global registry maps objects to their dependencies.

code
const targetMap = new WeakMap<object, Map<string | symbol, Dependency>>();
let activeEffect: EffectFunction | null = null;

WeakMap rather than Map matters more than it looks: state objects should die when the application drops them, and a strong registry would pin every object that was ever read into memory for the lifetime of the page.

Reading registers, writing notifies.

code
export function track(target: object, prop: string | symbol) {
  let depsMap = targetMap.get(target);
  if (!depsMap) targetMap.set(target, (depsMap = new Map()));
  if (!depsMap.has(prop)) depsMap.set(prop, new Dependency());
 
  if (activeEffect) {
    const dep = depsMap.get(prop)!;
    dep.depend(activeEffect);
    effectDeps.get(activeEffect)?.add(dep); // for cleanup later
  }
}

track runs from a Proxy get. trigger runs from a Proxy set. The mutable global activeEffect is how a getter knows who is asking — there is no argument threading it through, because the reads happen inside arbitrary user code that knows nothing about the system reading over its shoulder.

That is the entire premise. An effect sets itself as activeEffect, runs, and every property it touches on the way registers it. Nobody declares dependencies. They are discovered by execution.

Discovery by execution has a cost

If dependencies are discovered by running the effect, they change every time the effect runs. A conditional branch reads state.a on Monday and state.b on Tuesday, and the subscription to a is now stale.

Leave those in place and subscriber sets grow forever. Every re-run adds subscriptions and removes none, and after a few thousand updates you are notifying thousands of dead closures for a value nobody reads.

So each effect owns the set of dependencies it joined, and clears it before re-running:

code
const wrappedEffect = (() => {
  if (!wrappedEffect.active) return;
 
  // Unsubscribe from everything, then re-discover from scratch.
  deps.forEach((dep) => dep.remove(wrappedEffect));
  deps.clear();
 
  effectStack.push(wrappedEffect);
  activeEffect = wrappedEffect;
  try {
    effectFn();
  } finally {
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1] || null;
  }
}) as EffectRunner;

Two details in there took real iteration.

The effectStack is a stack rather than a single slot because effects nest. Restoring activeEffect to null after an inner effect finishes would silently orphan every remaining read in the outer one — the outer effect keeps running, keeps reading state, and registers none of it. You get an effect that works the first time and never re-runs, which is the least debuggable failure a reactive system can produce.

The finally is the other one. If a user effect throws halfway through, the stack still has to unwind, or activeEffect stays pointed at a dead effect and every subsequent read in the application subscribes something that will never run again. One exception, and tracking is permanently corrupt.

Where collections break the model

Here is the assumption a Proxy-based system makes and does not say out loud: every meaningful read is a property access on the object itself.

For a plain object that holds. state.user.name is a get trap on state, then a get trap on user. Both track.

For a Map, it is false. map.get("apple") is one property access — reading the get method — followed by a function call the Proxy cannot see. The dependency is not on the property get. It is on the key "apple", which never appears as a property at all.

So collections get instrumented methods rather than trap-level tracking:

code
const instrumentations = {
  get(key: any) {
    const rawKey = toRaw(key);
    const result = target.get(rawKey);
    track(target, rawKey); // depend on the KEY, not the method
    return wrap(result);
  },
 
  has(key: any) {
    const rawKey = toRaw(key);
    track(target, rawKey);
    return target.has(rawKey);
  },
};

Iteration is the same problem one level up. for (const x of set) does not read any single key — it reads the shape of the collection. There is no property to attribute that to, so iteration and size and forEach all track a synthetic key:

code
const iteratorMethods = ["keys", "values", "entries", Symbol.iterator];
 
iteratorMethods.forEach((method) => {
  instrumentations[method] = function (...args) {
    track(target, "size"); // "the membership of this collection"
    // ...wrap the inner iterator so nested values stay reactive
  };
});

"size" is doing double duty: it is a real property and the channel for "the set of things in here changed". That is a small lie in the design and I would name it ITERATE_KEY if I were starting again — but it is a lie with a consistent meaning, which is the difference between a shortcut and a bug.

The bug

Now clear(). My first implementation was the obvious one:

code
clear() {
  target.clear();
  trigger(target, "size"); // membership changed. done?
}

It is not done. It notifies everyone iterating the collection, and nobody who subscribed to a specific key. An effect that ran inventory.items.get("apple") registered against the key "apple". clear() never touches "apple". The effect never re-runs. It holds the last value it read, forever, for a key that does not exist.

The symptom in a real app: a cart badge showing 3 items after the cart was emptied. It updated correctly on every add, every remove, every quantity change, and stayed wrong only on the one operation nobody writes a test for.

The fix is to notify the keys that were there, which means capturing them before the destruction:

code
clear() {
  const hadItems = target.size !== 0;
  const keysToInvalidate = hadItems
    ? target instanceof Map
      ? Array.from(target.keys())
      : Array.from(target.values())
    : [];
 
  const result = target.clear();
 
  if (hadItems) {
    batchEffects(() => {
      trigger(target, "size");
      for (const key of keysToInvalidate) trigger(target, key);
    });
  }
  return result;
}

batchEffects is not decoration there. Without it, clearing a 500-item Map fires 501 separate notification passes, and an effect subscribed to both size and a key runs twice. Batching collapses that into one flush:

code
export function batchEffects(fn: EffectFunction) {
  batchDepth++;
  let success = false;
  try {
    fn();
    success = true;
  } finally {
    batchDepth--;
    if (batchDepth === 0) {
      if (success) {
        const queue = [...effectQueue];
        effectQueue.clear();
        for (const effect of queue) {
          if (effect.active === false) continue;
          effect.scheduler ? effect.scheduler(effect) : effect();
        }
      } else {
        // The batch threw. Discard the queue rather than propagating
        // effects derived from state that was never fully written.
        effectQueue.clear();
      }
    }
  }
}

That else is the part I am most confident about and had to argue myself into. If a batch throws halfway, the state is partially mutated and the queued effects describe a world that never existed. Running them propagates an inconsistency outward into components and network calls. Dropping them leaves state inconsistent but contained — recoverable by the next write. Neither option is good. One of them is smaller.

The same bug, wearing a different hat

Once I knew what to look for, the same class of miss was sitting in set().

Updating an existing key to a new value fired trigger(target, key) and nothing else. Correct for anyone reading that key. Wrong for anyone iterating — a component rendering [...map.entries()] subscribed to "size", and "size" never fired because membership technically had not changed.

code
} else if (!Object.is(oldValue, value)) {
  trigger(target, rawKey);
  trigger(target, "size"); // iterator subscribers see values, not just keys
}

Object.is rather than !== so that setting NaN over NaN correctly does nothing, and -0 over +0 correctly does something.

The pattern across all of these: the invalidation surface of an operation is not the same as the properties it writes. Writing one key can invalidate a view that never named that key. I now think that sentence is the actual content of a reactivity system, and the Proxy is just plumbing.

Handing it to React

React has its own opinion about when to re-render, and the correct integration point is useSyncExternalStore — not useState in an effect, which tears under concurrent rendering.

The subtlety is what to return as the snapshot. React calls getSnapshot on every render and bails out only if the result is Object.is-equal to last time. Return a freshly built object describing the store and you have an infinite render loop, because a new object is never equal to the old one.

So the store hook does not snapshot the state at all. It snapshots a counter:

code
const versionRef = useRef(0);
 
const subscribe = useCallback(
  (cb: () => void) => {
    return store.subscribe(() => {
      versionRef.current++;
      cb();
    });
  },
  [store],
);
 
const getSnapshot = useCallback(() => versionRef.current, []);
 
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
 
return store; // the live proxy — React re-renders when the version moves

A monotonically increasing integer is a perfect snapshot: cheap to compare, never accidentally equal, and it requires building no intermediate object at all.

The selector hook cannot use that trick, because its whole purpose is to not re-render when the selected value is unchanged. It compares in the subscription instead, and only calls React's callback when the comparison fails:

code
const subscribe = useCallback(
  (cb: () => void) => {
    return store.subscribe(() => {
      try {
        const fresh = selector(store);
        if (!Object.is(selectedRef.current, fresh)) {
          selectedRef.current = fresh;
          cb(); // only now does React hear about it
        }
      } catch (error) {
        logger.warn(`Selector update failed: ${String(error)}`);
      }
    });
  },
  [store, selector],
);

The try is deliberate. A selector reading store.user.profile.name throws the moment user is null during a logout, and an exception thrown inside a store notification would otherwise take down every other subscriber in the same flush. One component's bad selector should not unmount the application.

The cached value uses a sentinel rather than null for "not yet computed":

code
const UNSET = Symbol("unset");
const selectedRef = useRef<T | typeof UNSET>(UNSET);

Because null and undefined are legitimate selector results, and ?? compute would recompute forever for anyone selecting a nullable field.

Practical consequences

If you use QuantaJS — or any Proxy-based reactive store — these follow from the above rather than from taste.

Select narrowly. Subscribing to a store subscribes to every top-level key, because the store's internal watcher reads them all to stay reactive.

code
// re-renders on any store change
const store = useStore("user");
 
// re-renders when name changes
const name = useStoreSelector("user", (s) => s.name);

Put derivation in getters, not components. Getters are computed, so they cache until a dependency moves. The same filter in a component body runs on every render, including renders caused by something unrelated.

Watch values, not containers. watch(() => store, ...) fires on everything. watch(() => store.currentView, ...) fires on one thing.

Batch related writes. Three assignments in one action are three flushes unless you say otherwise.

code
batchEffects(() => {
  cart.items = next;
  cart.total = recompute(next);
  cart.updatedAt = Date.now();
}); // one flush, one render

What I would change

The "size" overload is the honest wart. It works, it is consistent, and it conflates two different concepts under one key — a reader of the source has to know that trigger(target, "size") sometimes means "the count changed" and sometimes means "assume anything you iterated is stale."

Deep watching still polls. Every real alternative I have tried costs either a proxy per nested object at creation time or a structural diff per write, and for the size of state most applications hold, polling loses less than both. I am not happy with it. I have not found the version I would be happy with.

And the thing I got right by luck rather than judgment: refusing to let effects run after .stop(). Every single concurrency bug I hit during the 2.0 work ended at a check I added early for unrelated reasons.

code
wrappedEffect.stop = () => {
  if (!wrappedEffect.active) return; // idempotent
  wrappedEffect.active = false;
  deps.forEach((dep) => dep.remove(wrappedEffect));
  deps.clear();
};

A stopped effect gets removed from its dependencies and refuses to run if something still holds a reference. Belt and braces, for a system where the subscriber lists are snapshotted before iteration and a stale entry can outlive its removal by exactly one flush.


QuantaJS 2.0.0 is stable and on npm as @quantajs/core, @quantajs/react, and @quantajs/devtools. The source is on GitHub, and the collection instrumentation described above is in packages/core/src/core/create-reactive.ts if you want to check my work.