fw-03 — Analysis
Required invariants
- An effect re-runs only if a value it actually read changed. This is the whole value proposition; failing it means you built "re-run everything."
- Dependencies are cleared before each re-run. Otherwise a branch no longer read keeps firing — a dependency leak.
- The active-effect context is a stack, not a global. A nested effect must not steal the outer effect's dependencies.
computedis lazy and cached, and is simultaneously a dependent and a dependency.- Effects are batched and deduplicated, flushed on a microtask.
The glitch, and why laziness fixes it
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, computing
2 + 2 = 4 instead of 5. That transient wrong value is a glitch.
Fixes: evaluate in topological order, or make computed lazy so c pulls b. Laziness is cheaper,
which is why computed is specified lazy rather than eager.
Test for it. Most hand-written reactive systems have this bug and never notice, because the glitch is transient and the final value is correct.
What tracking cannot see
| Case | Why | Mitigation |
|---|---|---|
| Destructuring | reads once, then you hold a plain value | toRefs-style wrappers |
| Collections | methods must be instrumented, not just property access | wrap Map/Set/array methods |
Reads after await | outside the tracking context | re-establish, or forbid |
| Conditional reads | stale deps accumulate | cleanup before re-run |
The trade against explicit models
Automatic tracking removes an authoring burden and adds an observability burden. With Redux you print the action log. With a reactive system, "why did this effect run?" needs a devtool that shows the graph — which is why every mature reactive framework ships one.