---
title: 'Atom-Based Approaches'
source: 'https://academia.sh/en/courses/frontend-architecture/atom-based-approaches'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.
