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-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 |
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:
- 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.
- 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
| Capability | Evidence you have it |
|---|---|
| Compare change-detection models | Argue signals vs virtual DOM on cost curves, and name where each reverses |
Build a correct computed | Lazy, cached, glitch-free on a diamond, and you can state the invariants |
| Explain lost reactivity | Destructuring, collections, async boundaries — from the mechanism |
| Choose for an organisation | Decide 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-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
Core terminology
| Term | Definition |
|---|---|
| Reactive object | A Proxy whose get/set traps record and notify dependencies |
ref | The primitive-value case; needs .value because primitives cannot be proxied |
| Effect | A 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 set | The effects subscribed to one property; and, inversely, the properties one effect read |
| Effect stack | Why "currently running effect" must be a stack, not a single global — nested effects |
| Cleanup | Clearing an effect's recorded dependencies before re-running, so stale edges disappear |
computed | A lazy, cached derivation that is both a dependent and a dependency |
| Push model | Eagerly recompute dependents on write; causes glitches |
| Pull model | Mark dirty on write, recompute on read; must walk the graph per read |
| Push-pull | Propagate invalidation on write, recompute lazily on read — what real systems do |
| Glitch | A transient wrong value from evaluating a node before its inputs are current |
| Diamond | a→b→d, a→c→d; the canonical shape that exposes glitches and double evaluation |
| Topological order | Evaluation order that guarantees inputs are current before dependents |
| Batching | Deduplicated queue of pending effects, flushed on a microtask |
untrack / peek | The deliberate escape hatch: read without recording a dependency |
| Fine-grained | Update 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
-
"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.
-
"Fine-grained reactivity has no author burden." It moves the burden. You must not destructure reactive objects, must not read across
awaitboundaries carelessly, and must understanduntrack. Those are rules too — just less visible ones, which is arguably worse. -
"
computedis 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. -
"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.
-
"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.
-
"Destructuring is a style choice."
const { a } = reactiveObjreadsaonce and hands you a plain value. Tracking is gone, silently. This is whytoRefsexists. -
"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.
-
"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."
- "
computedbeing 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
-
"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?
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'sreactivitypackage 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.