Skip to content
Skein
← All projects

frontend · intermediate · 4d

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.

Every modern reactive framework uses some variant of signals. By building one from scratch you'll understand exactly how dependency tracking works (the 'currently-executing' trick), why dynamic clearing matters for conditional branches, how lazy cached computeds transitively track through to sources, and why glitches happen in naive push graphs and how topological ordering plus batching fix them. The final milestones turn the toy into a product: untrack and cleanup for resource safety and a 10k-node benchmark plus a 1000-chain incident that proves batching is load-bearing.

Deliverable

A ~100-line library where signal → computed → effect chains update exactly once per batch, with a test suite proving no stale reads, no glitches (diamond), dynamic deps, and a 10k-node benchmark showing O(1) batch cost.

Milestones

0/5 · 0%
  1. 01signal() + effect() with auto-tracking

    Implement signal() and effect() with automatic dependency tracking via a global 'currently-executing' variable. A signal holds a value and a subscriber set; effect(fn) sets the global to itself, runs fn, and any signal read inside fn checks the global and adds the effect to its subscribers — no explicit `effect.subscribe(signal)` list. Writing a signal (`signal.value = x`) re-runs exactly its dependent effects. Dependencies are dynamic: before each re-run the effect's previous subscriber set is cleared so a signal conditionally read in one branch (e.g. `if (flag.value) a.value else b.value`) stops triggering the effect when that branch is no longer taken — without clearing, stale subscriptions cause unnecessary re-runs and prevent GC while signals are alive. Prove it: an effect that reads signal A in the true branch and B in the false branch unsubscribes from the untaken branch when the condition flips, and a test where a signal stops being read stops triggering the effect.

    Definition of done
    • Reading a signal inside an effect auto-subscribes via the currently-executing global; writing the signal re-runs exactly the dependent effects — verified with a direct read/write test.
    • An effect that stops reading a signal (conditional branch flip) is no longer re-run by it; a test with `if (flag) a else b` flipping proves dynamic unsubscription and no stale extra run.
    Self-review

    Show the conditional-branch test where flipping flag unsubscribes from the untaken signal. A senior reviewer checks the subscriber set is cleared before each re-run and that nesting (effect inside effect) saves/restores the global.

  2. 02Lazy cached computed()

    Add computed(): lazy, cached, transitively tracked, and never re-evaluated unless a source actually changed. A computed holds a thunk `() => value`, a dirty flag, and its own subscriber set; it only evaluates on read (lazy) and returns the cached value if no source dirtied it since the last read. On evaluation it also participates in dependency tracking — so a computed read inside an effect transitively subscribes the effect to the computed's source signals, not just to the computed node itself. The chain is: source write → mark computed dirty → schedule effect → on flush, computed re-evaluates lazily (once) before the effect reads it. Prove it: a computed whose source hasn't changed returns the cached value with zero recomputations (count the thunk calls); a computed read inside an effect causes the effect to re-run when the source changes but not when an unrelated signal changes; and changing the source, reading the computed twice, and asserting the thunk ran exactly once.

    Definition of done
    • computed() is lazy (no eval until first read) and cached (same value returned with zero thunk calls if no source changed) — verified by counting thunk invocations.
    • A computed read inside an effect transitively tracks through the computed to its sources; changing the source re-runs the effect and the computed evaluates exactly once on the next flush (counted).
    Self-review

    Show the thunk-call count proving lazy caching and the transitive-tracking test (source → computed → effect, one eval on flush). A senior reviewer checks the dirty flag is set on source write and cleared on read, and that unrelated signal changes don't re-run.

  3. 03Glitch-free batching

    Implement batching so multiple signal writes inside `batch(() => {...})` trigger each effect exactly once, after all writes, with no stale intermediate reads (glitches). The glitch: in a naive push graph, writing A immediately propagates to B and C, which immediately run their subscribers. If D depends on both B and C (diamond A→B, A→C, B&C→D), D runs when B updates (reading a stale C) and again when C updates — two evaluations, one with an inconsistent intermediate state. The fix is to defer flushing: mark dirty nodes, sort the reactive graph topologically, and flush in topological order so a computed is never evaluated while any of its sources are still dirty. Prove it with the diamond test: A→B, A→C, B&C→D updating D once with no stale read when A changes inside a batch, plus a count that D's effect ran exactly once for two writes inside batch. Explain why naive BFS/push without topological ordering produces glitches.

    Definition of done
    • Multiple writes inside batch(() => …) re-run each effect exactly once, after all writes — verified by counting effect invocations for two writes in one batch.
    • A diamond dependency (A→B, A→C, B&C→D) updates D once with no stale intermediate read when A changes inside a batch — D's effect sees the final consistent B and C, not a stale C on first run.
    Self-review

    Show the diamond test with effect-run count = 1 and no stale read, plus the two-writes-one-effect batch test. A senior reviewer checks the graph is topologically sorted before flush and can explain why BFS push would glitch.

  4. 04untrack() and effect cleanup

    Add untrack() to read a signal without subscribing and onCleanup() so effects can release resources before re-running. untrack(() => signal.value) evaluates the function with the currently-executing global temporarily nulled so the read is invisible to dependency tracking — essential when an effect needs to read a value without becoming reactive to it (e.g. reading a logger signal or a previous-value cache). onCleanup(fn) registers a callback that runs before the effect's next re-run (and on disposal), so subscriptions, timers, or abort controllers created in the previous run are torn down before the next — without it, each re-run would leak. Prove both: untrack reading signal B inside an effect subscribed to A does not cause B's writes to re-run the effect; an effect that creates a timer and registers onCleanup(() => clearTimeout) cleans up exactly once per re-run with no leaked timers.

    Definition of done
    • untrack(() => sig.value) inside an effect does not subscribe the effect to sig — writing sig does not re-run the effect, verified with a tracking vs untracked read test.
    • onCleanup(fn) runs before each effect re-run and on dispose; an effect that creates a timer and registers cleanup shows zero leaked timers after N re-runs (counted).
    Self-review

    Show untrack not subscribing (B write doesn't re-run) and onCleanup called once per re-run with no leaks. A senior reviewer checks untrack nulls the global only for its callback and that cleanup runs before re-run, not after.

  5. 05Benchmark and observe the graph

    Prove the library scales and make the reactive graph observable. Benchmark: create 10k signals with a fan-out computed graph and measure batch cost — the topological sort and flush should be O(nodes + edges), not O(nodes × writes), so batching 100 writes still triggers each effect once and the cost is proportional to graph size, not write count. Emit lightweight metrics for the library itself: effect run count, computed eval count, batch flush count and duration. Then work an incident: create a 1000-node chain and kill batching (write without batch) to watch the same chain trigger 1000 cascading updates vs 1 batched flush — the performance cliff is the proof that batching is load-bearing. Document the complexity: per-write marking is O(subscribers), flush is O(sorted dirty nodes), and why virtualized rendering of the graph (not the library) would be a separate concern.

    Definition of done
    • A 10k-node benchmark shows batch cost is O(graph), not O(writes): 100 writes in one batch trigger each effect once and flush count is 1, with effect/computed counts recorded.
    • A 1000-node chain without batching shows cascading N updates vs 1 batched flush — the cliff is measured and the complexity (mark O(subscribers), flush O(dirty)) is documented.
    Self-review

    Show the 10k benchmark (100 writes, 1 flush, per-effect count) and the 1000-chain cliff without batching. A senior reviewer checks flush is topologically ordered, cost is O(graph) not O(writes), and metrics are emitted.

Starter

fallowlone/skein-projects

projects/signals-mini

Open on GitHub ↗
  • README.md
  • src/signals.ts
  • test/signals.test.ts
Grab just this project npx degit fallowlone/skein-projects/projects/signals-mini signals-mini

Implement the stubs, then run the tests until they pass: bun test

Fork the repo and push your work — the grade workflow runs the suite plus static checks on your own runners.

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

  • Add effect scopes and a dispose() that tears down all effects in a scope — and prove no effect fires after its scope is disposed, with cleanup callbacks run once.
  • Add a DevTools graph inspector that visualizes the dependency edges and highlights dirty nodes during a batch flush.
  • Implement a proxy-based deep signal (reactive object) on top of the primitive signals and measure the overhead vs fine-grained signals on a 10k-property update.

Skills

reactive graph / pull-push modeldependency tracking via execution contexttopological sort for glitch-free updatesbatching & lazy computed cachinguntrack & effect cleanup (resource safety)

Suggested stack

typescriptvitest

Resources