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.
import { createStore } from "@quantajs/core";
const counter = createStore("counter", {
state: () => ({ count: 0 }),
actions: {
increment() {
this.count++;
},
},
});
counter.increment();
console.log(counter.count); // 1I 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.
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.
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.
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:
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:
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:
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:
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:
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:
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.
} 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:
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 movesA 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:
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":
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.
// 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.
batchEffects(() => {
cart.items = next;
cart.total = recompute(next);
cart.updatedAt = Date.now();
}); // one flush, one renderWhat 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.
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.