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-style | Vue reactivity | Signals | Redux | |
|---|---|---|---|---|
| How does it know what changed? | re-run and diff | tracked at property granularity | tracked at signal granularity | you tell it (actions) |
| Unit of update | component subtree | effect | effect | subscriber (all) |
| Author burden | keys, deps | almost none | almost none | action discipline |
| Cost scales with | tree size | number of reads | number of reads | number of subscribers |
| Debuggability | render trees, explicit | implicit graph | implicit graph | best: explicit log |
Fill this in yourself as you build; do not copy it. §47 wants your version.
3. Build order — mini-vue reactivity
reactive(obj)viaProxy— get/set traps.effect(fn)— a global "currently running effect" and a dependency map.- Dependency tracking:
track(target, key)on get,trigger(target, key)on set. ref(value)— the primitive-value case, and why it needs.value.computed(fn)— lazy, cached, and itself both a dependent and a dependency.watch(source, cb).- Cleanup: effects must drop stale dependencies on re-run.
- Nested effects — an effect stack, not a single global.
- 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
- 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.
- 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.
- Stale computed. Break the cache invalidation so a computed returns an old value. Explain why lazy caching is harder than it looks.
- Lost reactivity. Destructure a reactive object and lose tracking. Explain to a hypothetical junior in three sentences.
- Nested effect corruption. Use a single global "current effect" instead of a stack; watch the inner effect steal the outer's dependencies.
- 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.
| Model | On write | On read | Problem |
|---|---|---|---|
| Pure push | eagerly recompute all dependents | free | recomputes values nobody reads; glitches |
| Pure pull | mark dirty only | recompute if dirty | must walk the graph on every read |
| Push-pull (what real systems do) | mark dirty, propagate invalidation | recompute if dirty, cache result | the 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
computedis 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 } = sreadsaonce, then you hold a plain value. Tracking is lost. (This is why Vue hastoRefs.) - Collections —
Map,Set, arrays need their methods instrumented, not just property access. - Async boundaries — reads after an
awaithappen 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:
- N writes, N runs. A loop of 1,000 mutations runs the effect 1,000 times.
- Inconsistent intermediate states. An effect reading two values sees the first updated and the second not.
- 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
| Redux | React | Vue reactivity | Signals | |
|---|---|---|---|---|
| Who knows what changed | you (actions) | nobody — re-run and diff | the runtime (tracked reads) | the runtime |
| Granularity of update | whole subscriber set | component subtree | effect | effect |
| Work proportional to | subscribers | rendered tree size | number of tracked reads | number of tracked reads |
| "State at time T" exists? | yes, as one value | as props/state per component | no — scattered | no — scattered |
| Debug question | "which action?" | "which component re-rendered?" | "which dependency fired?" | same |
| Tooling required | log (trivial) | render profiler | dependency graph inspector | same |
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
-
"Signals are faster than the virtual DOM." Give the accurate statement, including the workload where it reverses.
-
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.
-
computedis lazy and cached. Enumerate the invariants that make caching safe, and the bug from breaking each. -
Why do refs need
.value? Answer from the mechanism, then say what a language feature would have to provide to remove it. -
Nested effects require a stack. Construct the concrete corruption a single global causes.
-
Compare Vue reactivity with signals: what is genuinely different, and what is naming?
-
A team proposes replacing Redux with signals in a large app. Argue both sides in terms of debuggability and incident response, not performance.
-
Design a reactivity system where dependencies are known at compile time. What must you forbid in the authoring language to make it sound?