Concepts — Reactivity: mini-vue and mini-signals

Phase 3, parallel with bi-08 · Spec areas §29, §36. Prerequisites: fw-02 (so the contrast has something to contrast with).

Build both, back to back. Separating them by months destroys the comparison, which is the entire content of §36. Vue's reactivity and signals are the same idea at different granularities; seeing that is the lesson.


0. Orientation

The 60-second version

fw-02 built a system that recomputes and compares. This module builds the opposite: a system that knows exactly what changed because it watched you read it.

The whole trick is one sentence: reading is instrumented. A Proxy get-handler knows which effect is currently running and records the edge between them. That is why a reactive system needs no keys, no diffing, and no dependency arrays — the runtime observes the dependency instead of being told about it.

The cost is equally specific, and it is not a footnote: you must intercept every read. Which means proxies with their own overhead and edge cases, special handling for collections, a dependency graph that scales with reads rather than with components, and — most importantly — a graph that is invisible unless you build a tool to show it.

The trade, stated properly

React-styleVue reactivitySignalsRedux
How does it know what changed?re-run and difftracked at property granularitytracked at signal granularityyou tell it (actions)
Unit of updatecomponent subtreeeffecteffectsubscriber (all)
Author burdenkeys, depsalmost nonealmost noneaction discipline
Cost scales withtree sizenumber of readsnumber of readsnumber of subscribers
Debuggabilityrender trees, explicitimplicit graphimplicit graphbest: explicit log

The last row is the one that decides real arguments. Automatic tracking removes an authoring burden and replaces it with an observability burden — which is precisely why every mature reactive framework ships a dependency inspector, and why fw-01's explicit model is not simply obsolete.

The two hard problems to look for

Most hand-written reactivity systems have both bugs and never notice, because the final value is right and the wrong one was transient:

  1. Glitches. A push-based system can evaluate a node before one of its inputs has updated, producing a briefly-wrong value. §6.5 has the diamond that reproduces it.
  2. Dependency leaks. An effect that stops reading a branch keeps firing on it unless dependencies are cleared and re-collected on every run.

What you should be able to do after this module

CapabilityEvidence you have it
Compare change-detection modelsArgue signals vs virtual DOM on cost curves, and name where each reverses
Build a correct computedLazy, cached, glitch-free on a diamond, and you can state the invariants
Explain lost reactivityDestructuring, collections, async boundaries — from the mechanism
Choose for an organisationDecide on debuggability and incident response, not benchmarks

1. Why a Principal Engineer needs this

fw-02 built a system that recomputes and diffs. This module builds the opposite: a system that knows exactly what changed and updates only that. Holding both models precisely is what lets you evaluate any UI framework — including ones that do not exist yet — instead of tracking fashion.


2. Mental Model

reactive object
   -> property read inside a running effect  => record dependency
   -> property write                          => notify dependents
   -> scheduler decides when effects actually run

The core trick: reading is instrumented. A Proxy get-handler knows which effect is currently running and records the edge. This is why no keys, no diffing and no dependency arrays are required — the runtime observes the dependency rather than being told.

The cost is equally specific: you must intercept every read. Which means proxies (with their own costs and edge cases), care around collections and non-reactive values, and a dependency graph whose size scales with reads, not with components.

The comparison, stated precisely

React-styleVue reactivitySignalsRedux
How does it know what changed?re-run and difftracked at property granularitytracked at signal granularityyou tell it (actions)
Unit of updatecomponent subtreeeffecteffectsubscriber (all)
Author burdenkeys, depsalmost nonealmost noneaction discipline
Cost scales withtree sizenumber of readsnumber of readsnumber of subscribers
Debuggabilityrender trees, explicitimplicit graphimplicit graphbest: explicit log

Fill this in yourself as you build; do not copy it. §47 wants your version.


3. Build order — mini-vue reactivity

  1. reactive(obj) via Proxy — get/set traps.
  2. effect(fn) — a global "currently running effect" and a dependency map.
  3. Dependency tracking: track(target, key) on get, trigger(target, key) on set.
  4. ref(value) — the primitive-value case, and why it needs .value.
  5. computed(fn) — lazy, cached, and itself both a dependent and a dependency.
  6. watch(source, cb).
  7. Cleanup: effects must drop stale dependencies on re-run.
  8. Nested effects — an effect stack, not a single global.
  9. Scheduling and batching: a microtask-flushed queue, deduplicated.

4. Build order — mini-signals

Then rebuild the same capability with a minimal signal API (signal, computed, effect, batch). Deliberately do not reuse the Vue code. The point is to find out how much of it was essential and how much was Vue-specific.


5. Failure Lab

  1. Dependency leak. Omit cleanup on re-run. Build a case where an effect depends on a branch it no longer reads, and keeps firing. This is the bug cleanup exists for.
  2. Infinite loop. An effect that writes a value it reads. Predict, then observe. Then design the guard — and note what legitimate patterns your guard forbids.
  3. Stale computed. Break the cache invalidation so a computed returns an old value. Explain why lazy caching is harder than it looks.
  4. Lost reactivity. Destructure a reactive object and lose tracking. Explain to a hypothetical junior in three sentences.
  5. Nested effect corruption. Use a single global "current effect" instead of a stack; watch the inner effect steal the outer's dependencies.
  6. Batching absence. 1,000 writes in a loop with no scheduler. Measure. Then batch.

6. Trade-offs to argue

Fine-grained updates vs graph overhead. Tracking is not free: every read does bookkeeping. Find the workload where React's "re-render and diff" wins.

Implicit dependencies vs explicit ones. Automatic tracking removes an author burden and removes the author's ability to see the graph. Which do you want when debugging a production incident? (This is why fw-01's explicit model is not simply obsolete.)

Proxy-based vs compile-time. Vue tracks at runtime; some frameworks move dependency analysis to compile time. What does each know that the other cannot? (This is §48 Challenge B, and fw-06 is where you would build it.)


6.5 Deep dive: push, pull, and why computed is hard

Reactivity systems differ on when work happens, and the vocabulary is worth having.

ModelOn writeOn readProblem
Pure pusheagerly recompute all dependentsfreerecomputes values nobody reads; glitches
Pure pullmark dirty onlyrecompute if dirtymust walk the graph on every read
Push-pull (what real systems do)mark dirty, propagate invalidationrecompute if dirty, cache resultthe invalidation must reach everything, exactly once

computed is the hard case because it is both a dependent and a dependency. When its source changes it must not recompute (nobody may want it) but it must invalidate its own dependents, who may then pull.

The glitch problem

const a = signal(1);
const b = computed(() => a.value + 1);
const c = computed(() => a.value + b.value);

Set a = 2. A naive push order can evaluate c after a updated but before b did, so c briefly computes 2 + 2 = 4 instead of 2 + 3 = 5. That transient wrong value is a glitch.

Real systems avoid it by evaluating in topological order, or by making computed lazy so c pulls b and b recomputes on demand. Laziness is the cheaper fix and is why computed is specified as lazy rather than eager.

Test for this in your implementation. If your computed is eager, construct the diamond above and watch for the intermediate value. Most hand-written reactivity systems have this bug and never notice, because the glitch is transient and the final value is right.

The diamond, generalised

a → b → d and a → c → d is the canonical shape. A correct system evaluates d once, after both b and c are current. A naive one evaluates d twice, and possibly once with stale input. Count evaluations in your lab — that count is the difference between a toy and a real implementation.


6.6 Deep dive: what dependency tracking cannot see

Automatic tracking works by instrumenting reads. Anything that is not a tracked read is invisible:

const s = reactive({ items: [] });

effect(() => { console.log(s.items.length); });
s.items.push(1);        // does the effect re-run? depends on whether the ARRAY is reactive

The hard cases, all of which you should build and break:

  • Destructuring — const { a } = s reads a once, then you hold a plain value. Tracking is lost. (This is why Vue has toRefs.)
  • Collections — Map, Set, arrays need their methods instrumented, not just property access.
  • Async boundaries — reads after an await happen outside the tracking context unless the system re-establishes it. This is the subtlest bug in the module and it is worth a deliberate failure lab.
  • Conditional reads — handled by cleanup (spec stage 5), and the reason cleanup exists.
  • Untracked escape hatches — every system needs one (untrack, peek), and every one is a place where a dependency is deliberately not recorded.

The trade against explicit models (fw-01) restated: automatic tracking removes an authoring burden and replaces it with an observability burden. With Redux you can print the action log. With a reactive system, "why did this effect run?" requires a devtool that shows the graph — which is why every mature reactive framework ships one.


6.7 Deep dive: scheduling, and why reactivity needs one at all

Naive triggering runs effects synchronously on write. Three problems follow immediately:

  1. N writes, N runs. A loop of 1,000 mutations runs the effect 1,000 times.
  2. Inconsistent intermediate states. An effect reading two values sees the first updated and the second not.
  3. Re-entrancy. An effect that writes triggers effects mid-flight.

So every real system has a scheduler: a deduplicated queue, flushed on a microtask.

Notice what that is: bi-11's microtask checkpoint, used as a batching boundary. The framework chose a microtask because it is the earliest point at which the current synchronous work is finished — the same reason MutationObserver delivers there (bi-04).

Pre-flush vs post-flush ordering matters too: component render effects must run before the DOM is read by anything that needs current geometry, and watch callbacks with flush: 'post' run after the DOM updates precisely so they can measure. That option exists because someone hit forced synchronous layout (bi-08).


6.8 Deep dive: the four models, on one axis that is not speed

ReduxReactVue reactivitySignals
Who knows what changedyou (actions)nobody — re-run and diffthe runtime (tracked reads)the runtime
Granularity of updatewhole subscriber setcomponent subtreeeffecteffect
Work proportional tosubscribersrendered tree sizenumber of tracked readsnumber of tracked reads
"State at time T" exists?yes, as one valueas props/state per componentno — scatteredno — scattered
Debug question"which action?""which component re-rendered?""which dependency fired?"same
Tooling requiredlog (trivial)render profilerdependency graph inspectorsame

The row that decides real architecture decisions is the fourth: does "the state at time T" exist as a value? Time travel, serialisation, undo, and crash-report state dumps all follow from it, and no amount of update efficiency substitutes.

Fill this table from your own implementations before reading anyone's comparison. §47 wants your version, and the exercise is worthless if it is copied.


7. Verification

  • mini-vue reactivity: all nine stages
  • mini-signals built independently
  • All six failure-lab bugs reproduced and fixed
  • Measured: update cost vs mini-react on (a) one deep change, (b) a broad change
  • §47 comparison table filled in your own words
  • Complexity-notebook entry: the Vue scheduler

Core terminology

TermDefinition
Reactive objectA Proxy whose get/set traps record and notify dependencies
refThe primitive-value case; needs .value because primitives cannot be proxied
EffectA function re-run when anything it read changes
track(target, key)Called on read: record an edge from the running effect to this property
trigger(target, key)Called on write: notify effects that recorded that edge
Dependency setThe effects subscribed to one property; and, inversely, the properties one effect read
Effect stackWhy "currently running effect" must be a stack, not a single global — nested effects
CleanupClearing an effect's recorded dependencies before re-running, so stale edges disappear
computedA lazy, cached derivation that is both a dependent and a dependency
Push modelEagerly recompute dependents on write; causes glitches
Pull modelMark dirty on write, recompute on read; must walk the graph per read
Push-pullPropagate invalidation on write, recompute lazily on read — what real systems do
GlitchA transient wrong value from evaluating a node before its inputs are current
Diamonda→b→d, a→c→d; the canonical shape that exposes glitches and double evaluation
Topological orderEvaluation order that guarantees inputs are current before dependents
BatchingDeduplicated queue of pending effects, flushed on a microtask
untrack / peekThe deliberate escape hatch: read without recording a dependency
Fine-grainedUpdate granularity at the effect/DOM-node level rather than the component level

Mental models

Reading is the instrumented operation. In React you declare what an update depends on; here the runtime learns it by watching. Everything good and everything costly follows from that one inversion.

Laziness is a correctness mechanism, not just an optimisation. computed is specified as lazy because eager evaluation produces glitches on a diamond. A lazy computed pulls its inputs, which forces them to be current, which makes topological ordering unnecessary. That is why "lazy and cached" appear together in every real implementation.

Cleanup is what makes conditional reads correct. An effect's dependency set must be rebuilt each run, not accumulated. Otherwise an effect that took a different branch this time still fires on the branch it no longer reads — the bug that cleanup exists for, and the reason dependency tracking must be re-entrant and stack-based.

Every re-running system must define what "undo the previous run" means. Effect cleanup here, useEffect cleanup in fw-02, hot.dispose in fw-07, teardown in fe-02. Systems that skip that definition are the ones that leak.

Automatic tracking trades an authoring burden for an observability burden. With Redux you print the action log. With a reactive system, "why did this effect run?" requires a tool that renders the graph. That is a real operational cost, and it is the strongest argument in the room when a team proposes replacing an explicit store in a large application.

The scheduler is not an optimisation either. Without one you get N runs for N writes, inconsistent intermediate states, and re-entrancy. Every real system has a deduplicated queue flushed on a microtask — which is fe-01's microtask checkpoint used as a batching boundary, chosen because it is the earliest point at which the current synchronous work is finished.


Common misconceptions

  1. "Signals are faster than the virtual DOM." They have a different cost curve: work proportional to tracked reads rather than to tree size. That reverses when reads are numerous and the rendered tree is small, and tracking overhead is real per-read bookkeeping.

  2. "Fine-grained reactivity has no author burden." It moves the burden. You must not destructure reactive objects, must not read across await boundaries carelessly, and must understand untrack. Those are rules too — just less visible ones, which is arguably worse.

  3. "computed is just a memoised getter." It is a node that is both dependent and dependency, which is what makes cache invalidation hard. Break any of its invariants and it returns a stale value with no error.

  4. "My reactivity system works — the values are all correct." Check for glitches on a diamond. The wrong value is transient and the final value is right, so the bug is invisible without a deliberate test.

  5. "A single 'currently running effect' global is fine." Nested effects corrupt it: the inner effect steals the outer's dependencies. It must be a stack.

  6. "Destructuring is a style choice." const { a } = reactiveObj reads a once and hands you a plain value. Tracking is gone, silently. This is why toRefs exists.

  7. "Reactivity removes the need for a scheduler." It creates the need for one. 1,000 writes in a loop run the effect 1,000 times without batching.

  8. "Proxies track everything." They track property access. Collection methods, async continuations, and values already extracted are all invisible unless explicitly handled.


Interview talking points

  • "The difference between React and signals is who knows what changed. React re-runs and compares; signals instrument the read, so the runtime observes the dependency instead of being told. That gives you cost proportional to reads instead of tree size — and it costs you the ability to see the graph."
  • "When a team wants to replace an explicit store with signals in a large app, I argue it on incident response, not performance. Redux gives you an action log you can print. A reactive system gives you 'why did this effect run?', which requires a dependency inspector — and that's why every mature reactive framework ships one."
  • "computed being lazy isn't an optimisation, it's how you avoid glitches. On a diamond, an eager push order can evaluate the dependent before one of its inputs has updated. Laziness makes the dependent pull its inputs, which forces them current. Most hand-written reactivity systems have that bug and never notice, because the transient value is wrong and the final one is right."
  • "Cleanup is what makes conditional reads correct — dependency sets have to be rebuilt each run, not accumulated. It's an instance of a rule I apply generally: any system that re-runs code has to define what 'undo the previous run' means, and the ones that skip that definition leak."
  • "Reactivity needs a scheduler for the same reason a framework needs batching: without one you get N effect runs for N writes, plus inconsistent intermediate states and re-entrancy. The microtask queue is the natural boundary because it's the earliest point at which the current synchronous work is done."

8. Principal Engineer Review

  1. "Signals are faster than the virtual DOM." Give the accurate statement, including the workload where it reverses.

  2. Automatic dependency tracking removes author burden and hides the graph. Argue this is the right default; then design the debugging tool that makes it acceptable at scale.

  3. computed is lazy and cached. Enumerate the invariants that make caching safe, and the bug from breaking each.

  4. Why do refs need .value? Answer from the mechanism, then say what a language feature would have to provide to remove it.

  5. Nested effects require a stack. Construct the concrete corruption a single global causes.

  6. Compare Vue reactivity with signals: what is genuinely different, and what is naming?

  7. A team proposes replacing Redux with signals in a large app. Argue both sides in terms of debuggability and incident response, not performance.

  8. Design a reactivity system where dependencies are known at compile time. What must you forbid in the authoring language to make it sound?


Connections to other modules

  • fw-01 — the explicit model, and the reason it is not obsolete: an action log beats an implicit graph during an incident.
  • fw-02 — prerequisite, and the contrast the whole module exists to draw.
  • fw-05 — where reactivity is joined to a renderer: wrap a component's render in an effect and re-render becomes component-precise rather than subtree-wide.
  • fw-06 — moving dependency analysis to compile time; what each of runtime and build time can know that the other cannot.
  • fw-11 — read Vue's reactivity package against your implementation: dep-set storage, cleanup, scheduler job ordering.
  • bi-07 — Blink's style invalidation is a change-detection system built to the same requirement in C++, at a different scale. It belongs in your §47 table as a row.
  • fe-01 — the microtask checkpoint used as a batching boundary, and why it must not be used as a yield.
  • fe-02 — dependency sets keyed by object are side tables; the eviction question is the same.