Part 5 · 2 chapters · ~18 min
M5: JS Framework
The fine-grained model behind Solid, Vue and Angular signals: signals that subscribe their readers, lazy cached computeds, effects that re-collect dependencies, tagged-template rendering with one effect per hole, batching, keyed lists and a hash router, and a bug a real browser found.
10
Signals, templates and keyed lists
reactivity without a virtual DOM
- Signals subscribe whoever reads them.
- Computed values are lazy and cached.
- Effects are eager, and re-collect their dependencies on every run.
- Templates are parsed once, with one effect per hole.
- Batching applies several updates together so no half-applied state shows.
- Keyed lists move existing rows instead of recreating them.
| signals (Solid, tiny-framework) | virtual DOM (React, tiny-react: M6) | |
|---|---|---|
| unit of update | one DOM node per changed signal | a component subtree, then diffed |
| dependency tracking | automatic, at runtime, by reading | explicit (deps arrays) or by the compiler |
| components at runtime | run once, to build the DOM and wire effects | run on every render |
| cost of an update | proportional to what changed | proportional to the re-rendered subtree |
| scheduling | synchronous (plus batching) | interruptible, prioritised (lanes) |
| mental model | a spreadsheet of cells | UI as a pure function of state |
TINY-FRAMEWORK: SIGNALS TO DOM
a dependency graph built by reading, updated by writing, ending in one text node
swipe the figure sideways, or tap expand for full screen
1/6
signals
Signals: count = signal(0), price = signal(1500). A signal is a value plus the set of computations that read it. Reading count() inside a running computation adds that computation to count's subscribers. There is no dependency array to maintain: the graph is whatever was actually read.
11
Building it: tiny-framework
Repo: repos/jsframework. Signals, the template, keyed lists and a router, plus a DOM shim for the tests and a browser demo.
code
// src/signals.ts: an effect re-collects its dependencies every time it runs
export function effect(fn: () => void | (() => void)): () => void {
const c: Computation = {
deps: new Set(),
run() {
c.cleanup?.();
unsubscribe(c); // forget last run's dependencies
const prev = current; current = c; // reads during fn() subscribe c
try { const r = fn(); c.cleanup = typeof r === 'function' ? r : undefined; }
finally { current = prev; }
},
};
c.run();
return () => { c.cleanup?.(); unsubscribe(c); };
}code
// src/html.ts: a child hole bound to a function: update the text node in place when possible
effect(() => {
const value = (v as () => unknown)();
const only = start.nextSibling;
if (only && only !== end && only.nextSibling === end && only.nodeType === 3 && (typeof value === 'string' || typeof value === 'number')) {
only.nodeValue = String(value); return; // the fast path
}
while (start.nextSibling && start.nextSibling !== end) start.parentNode!.removeChild(start.nextSibling);
end.parentNode!.insertBefore(toNode(value), end);
});Run it. In
repos/jsframework: npm test, then npm run build (which strips types with Node's built-in stripper) and serve the repo folder. Open examples/index.html: type a note into a row, press "Reverse order", and the note moves with its row. That is the keyed list keeping DOM identity.a bug the browser found
The first version handled
attr=${x} but not placeholder="note for ${x}", which mixes text and a hole. That hole was never consumed, so every later hole in the row was off by one. The amount showed the name, and the unit tests had all passed. The fix splits mixed attributes on the marker and consumes one hole per split, and a regression test now covers it. This is why every repo here is also checked against a real client.exercises
1. Ownership and cleanup: dispose a route's effects when it unmounts. 2. resource() for async data with loading and error signals. 3. Show and For helpers with keyed fallbacks. 4. Server rendering to a string, plus hydration that adopts the existing DOM.