Lesson 08 / 22
Atom-Based Approaches
Fine-grained reactive state — atom and derived atom, the dependency graph, invalidation propagation and lazy computation, comparing recompute and notification counts against the centralized store, and the graph model's cost.
Contents
The centralized store established a single source of truth, but it came with a cost: every change renews a single state object and is announced to every listener. Selectors prevent unnecessary redraws — meaning correctness depends on every consumer building its own filter flawlessly. When a selector is forgotten or produces a new object on every call, the defect is silent: the screen looks correct, it just redraws more than it should.
There is another model that reverses this responsibility. State is defined not as one large object but as small units linked to each other. Then who reads what falls out of the dependency itself, and the filtering work moves from the consumer to the graph.
Atom and Derived Atom
An atom is the smallest unit of state that can be independently read and written. In the North Slope Measurement Station application, the measurement list is an atom, the filter criterion is an atom, the sort direction is an atom, the interface theme is an atom. There is no enclosing object between them; each stands on its own.
A derived atom, in contrast, cannot be written; it is computed from its inputs. The filtered list, the sorted list, and the summary are each derived atoms. The rule from the State Types lesson becomes concrete here as a unit: a derived value is not stored, it is defined.
Together the two form a dependency graph. Edges are drawn in the read direction: the filtered list reads the measurement list and the filter. Views also behave like the graph’s leaves; they attach to the nodes they read.
The graph’s operation has two steps. When an atom is written, every derived node reachable from it is invalidated — its value is no longer considered valid. An invalidated node’s value is recomputed when it is read. No computation happens at write time.
// atom-graph.mjs — atoms, derived atoms, invalidation propagation, and recompute counting const recomputed = []; function atom(name, value) { return { name, kind: "atom", value, dependents: new Set() }; } function derived(name, inputs, compute) { const d = { name, kind: "derived", inputs, compute, dirty: true, value: undefined, dependents: new Set() }; for (const i of inputs) i.dependents.add(d); return d; } function consumer(name, inputs) { const c = { name, kind: "consumer", inputs, dependents: new Set() }; for (const i of inputs) i.dependents.add(c); return c; } function read(d) { if (d.kind === "atom") return d.value; if (d.dirty) { d.value = d.compute(...d.inputs.map(read)); d.dirty = false; recomputed.push(d.name); } return d.value; } function write(a, value, active) { recomputed.length = 0; a.value = value; const notified = new Set(); const invalidate = (d) => { for (const dep of d.dependents) { if (dep.kind === "consumer") { notified.add(dep.name); continue; } if (dep.dirty) continue; // already dirty, propagation stops dep.dirty = true; invalidate(dep); } }; invalidate(a); const rendered = active.filter((c) => notified.has(c.name)); // consumers off-screen are not notified for (const c of rendered) c.inputs.forEach(read); return { notified: rendered.map((c) => c.name), recomputed: [...recomputed] }; } const measurements = atom("measurements", [ { type: "temperature", v: -4.2 }, { type: "humidity", v: 71 }, { type: "temperature", v: -3.8 }]); const filter = atom("filter", "all"); const sortDir = atom("sortDir", "descending"); const theme = atom("theme", "light"); const filtered = derived("filtered", [measurements, filter], (m, f) => f === "all" ? m : m.filter((x) => x.type === f)); const sorted = derived("sorted", [filtered, sortDir], (m, s) => [...m].sort((a, b) => s === "ascending" ? a.v - b.v : b.v - a.v)); const summary = derived("summary", [filtered], (m) => ({ count: m.length, average: m.length ? m.reduce((t, x) => t + x.v, 0) / m.length : null })); const header = derived("header", [theme], (t) => ({ background: t === "dark" ? "#111" : "#fff" })); const List = consumer("List", [sorted]); const SummaryCard = consumer("SummaryCard", [summary]); const Header = consumer("Header", [header]); let active = [List, SummaryCard, Header]; active.forEach((c) => c.inputs.forEach(read)); // first render console.log("-- notified consumer and recompute per write --"); const steps = [ ["theme = dark", () => write(theme, "dark", active)], ["sortDir = ascending", () => write(sortDir, "ascending", active)], ["filter = temperature", () => write(filter, "temperature", active)], ["measurements += record", () => write(measurements, [...measurements.value, { type: "temperature", v: -5.1 }], active)], ]; let totalNotified = 0, totalRecomputed = 0; for (const [label, run] of steps) { const { notified, recomputed: recomp } = run(); totalNotified += notified.length; totalRecomputed += recomp.length; console.log(label.padEnd(25), "notified:", (notified.join(", ") || "-").padEnd(26), "recomputed:", recomp.join(", ") || "-"); } console.log("-- SummaryCard removed from screen --"); active = [List, Header]; const s = write(filter, "humidity", active); totalNotified += s.notified.length; totalRecomputed += s.recomputed.length; console.log("filter = humidity".padEnd(25), "notified:", (s.notified.join(", ") || "-").padEnd(26), "recomputed:", s.recomputed.join(", ") || "-"); console.log("summary dirty:", summary.dirty); console.log("-- total --"); console.log("fine-grained : notified", totalNotified, "| recomputed", totalRecomputed); console.log("single object: notified", 5 * 3, "| (every consumer on every write)");
-- notified consumer and recompute per write -- theme = dark notified: Header recomputed: header sortDir = ascending notified: List recomputed: sorted filter = temperature notified: List, SummaryCard recomputed: filtered, sorted, summary measurements += record notified: List, SummaryCard recomputed: filtered, sorted, summary -- SummaryCard removed from screen -- filter = humidity notified: List recomputed: filtered, sorted summary dirty: true -- total -- fine-grained : notified 7 | recomputed 10 single object: notified 15 | (every consumer on every write)
Each row shows the effect of a single write. When the theme changes, only the header node is recomputed; the measurement list is untouched. When the sort direction changes, the filtered list stays in place and only the sort is redone — the filtering work is not repeated. When the filter changes, three nodes are invalidated at once, because two of them depend on the filtered list.
One detail of the propagation is written into the code: propagation stops at a node that is already dirty. Even if a node is reached by two paths in the same write, it is marked once and computed once.
Lazy Computation
The write made after the summary card is removed from the screen shows the model’s second gain. The filtered list and sorted list are recomputed because the list is still on screen. The summary node, however, is only invalidated, not computed; the output’s last line confirms this.
The reason is that computation happens at read time, not at write time. A derived value nobody reads is never computed. When the summary card returns to the screen, the read occurs and the value is produced at that moment.
This behavior is the reactive counterpart of lazy evaluation from the Programming Fundamentals course, and it can be decisive on its own for expensive derivations: a computation that groups a thousand-row table never runs as long as that table is not shown.
Comparison with Coarse-Grained
The last two lines compare the two models in the same scenario. Across five writes, the fine-grained graph produced seven consumer notifications; the single-object store, without selectors, would have produced fifteen.
The difference is one selectors can close — but it is a difference that must be closed. In the atom model, the filter falls out of the dependency itself: a consumer depends on whatever it reads. In the centralized store, the filter is written by hand, and nothing warns when it is written wrong.
There is also something lost in return. In the centralized store, every change passes through a single action stream; it can be ordered, recorded, replayed. In the atom graph, writes go directly to the atom; the question “who changed this value” has no single point of answer. Action logging and replay do not come for free in this model.
The Graph’s Cost
Fine-grained granularity brings three new problems.
Scatter. The state definition is not collected in one place; atoms spread out next to where they are used. In a small application this is a gain. As it grows, finding out who writes a given atom turns into scanning files. The countermeasure is to put write operations behind named functions and not leave the atom directly writable.
Dependency cycle. A derived atom indirectly reading itself sends invalidation propagation into an infinite loop. The example’s invalidation propagation is partly protected against this — it stops at an already-dirty node — but the read side falls into infinite recursion. The graph must be checked for acyclicity as it is built; the directed acyclic graph concept from the Data Structures course is a constraint here.
Identity. When an atom is needed per list item — such as each measurement row’s own selected state — atoms form a family and are produced by key. Cleaning up this family is done by hand; if the atom for an item removed from the screen is not released, a memory leak forms. The memory leak diagnosis from the Asynchronous JavaScript and the Runtime course applies here directly.
Which Model Where
The two models are not alternatives to each other; they are tools chosen according to different criteria.
The centralized store is preferred when the traceability of change is the first priority: complex business rules, an undo requirement, multi-step flows, an action log delivered together with a bug report.
The atom model is preferred when granularity is the first priority: a large number of mutually independent small pieces of state, expensive derivations, values that update often and have few consumers.
The two can also coexist in the same application. The criterion is not the pattern itself but which question that piece of state raises.
Summary
- An atom is the smallest unit of state that can be read and written independently; a derived atom cannot be written, it is computed from its inputs and not stored.
- A write invalidates the nodes reachable in the graph; computation happens at read time, not write time, so a derived value nobody reads is never computed.
- Because invalidation propagation stops at an already-dirty node, every node is computed at most once per write.
- In the fine-grained graph, the filtering work falls out of the dependency itself; in the centralized store, the same result is obtained with hand-written selectors and is silently lost when written wrong.
- Because there is no single action stream in the atom model, action logging, replay, and undo do not come for free.
- Model choice depends on the criterion: traceability favors the centralized store, granularity favors the atom graph; the two can also be used together.
Next Step
Both models share one assumption: the application owns the state, and the written value is correct. The North Slope Measurement Station application’s largest mass of state does not meet this assumption. The station list and measurement records are copies of a remote source; another user may have added a new station, someone may have corrected a measurement. This state’s question is not “where is it held” but “how long is it considered current.” The next lesson covers server state in its own layer: the cache key, the freshness window, reducing two components requesting the same data to a single request, invalidation, and refetching.
To keep your progress and take notes, Log in
My notes
Log in to take notes.