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.


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:

  • Destructuringconst { a } = s reads a once, then you hold a plain value. Tracking is lost. (This is why Vue has toRefs.)
  • CollectionsMap, 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

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?