frontend · intermediate · 3d
Signals mini
Build a ~100-line reactive signals library (signal/computed/effect) with automatic dependency tracking and glitch-free batched updates — the same model that powers Solid, Preact Signals, and Vue 3.
Deliverable
A library where signal → computed → effect chains update exactly once per batch, with a test suite that proves no stale reads and no glitches.
Milestones
0/3 · 0%- 01signal() + effect() with auto-tracking
Implement signal() and effect(): use a global 'currently-executing' variable to auto-track reads and re-run effects on writes.
Definition of done- Reading a signal inside an effect auto-subscribes it; writing the signal re-runs exactly the dependent effects.
- An effect that stops reading a signal is no longer re-run by it (dependencies are dynamic).
- 02Lazy cached computed()
Add computed(): lazy, cached, and only recomputed when a source signal actually changed.
Definition of done- A computed recomputes only when a source actually changed and is cached between reads otherwise.
- A computed read inside an effect tracks transitively through the computed to its sources.
- 03Glitch-free batching
Implement batching so that multiple signal writes inside batch(() => {...}) trigger each effect exactly once.
Definition of done- Multiple writes inside batch(() => …) re-run each effect exactly once, after all writes.
- A diamond dependency (A→B, A→C, B&C→D) updates D once with no stale intermediate read.
Starter
- README.md
- src/signals.ts
- test/signals.test.ts
Unzip, implement the stubs, then run the tests until they pass: bun test
Rubric
| Junior | Mid | Senior | |
|---|---|---|---|
| Dependency tracking mechanism | Effects are re-run by subscribing explicitly (effect.subscribe(signal)); no automatic tracking — the developer must list every dependency by hand. | A global 'currently-executing' variable auto-tracks reads: any signal read inside an effect's function body is registered as a dependency without explicit listing. | Dependencies are dynamic: before each re-run the previous subscriber set is cleared so a signal conditionally read in one branch does not keep the effect subscribed when the branch is no longer taken. You can show a test where a signal that stops being read stops triggering the effect. |
| Glitch-free / topological propagation | Effects are re-run immediately on each signal write; a diamond dependency (A→B, A→C, B&C→D) causes D to run twice and may read a stale intermediate value on the first run. | Multiple writes inside batch() trigger each effect exactly once, after all writes; D updates once without a stale read when A changes inside a batch. | The reactive graph is sorted topologically before flushing so a computed is never evaluated while any of its sources are still dirty; you can prove this with the diamond test and explain why naive BFS/push propagation produces glitches without topological ordering. |
| Computed caching & lazy evaluation | computed() re-evaluates on every read, regardless of whether any source signal has changed since the last read. | computed() is lazy (only evaluates on read) and cached (returns the last value if no source changed); it re-evaluates only when marked dirty by a source write. | A computed read inside an effect tracks transitively through the computed to its source signals, so the effect subscribes to the sources — not just the computed node. You can show that changing a source causes the computed to be marked dirty, the effect to be scheduled, and the computed to re-evaluate exactly once on the next flush. |
Reference walkthrough (spoiler)
The 'currently-executing' trick: auto-dependency tracking works by maintaining a module-level variable that holds the currently-running effect (or null). When a signal's getter is called, it checks this variable and adds the effect to its subscriber set. When the effect finishes, the variable is restored to its previous value. Nesting works because the variable is saved and restored on each effect invocation.
Glitch: in a naive push-based reactive graph, writing signal A propagates immediately to B and C, which immediately run their subscribers. If D depends on both B and C, it runs when B updates (reading a stale C) and runs again when C updates — two evaluations, one with an inconsistent intermediate state. Topological ordering or batching prevents this by deferring all flush until all dirty nodes in the current batch are marked.
Dynamic dependencies: clearing the subscriber set before each re-run is necessary for correctness when effects use conditionals. If an effect reads signal A in the true branch and signal B in the false branch, and the condition flips, the effect must unsubscribe from the branch it no longer takes. Without clearing, stale subscriptions cause unnecessary re-runs and make the effect impossible to garbage-collect while its signals are alive.
Make it senior
- Prove glitch-freedom: sort the reactive graph topologically before flushing so a computed is never read while its sources are dirty.
- Add untrack() to read a signal without subscribing, and onCleanup() so effects can release resources before re-running.