Skip to content
academia.sh

Lesson 22 / 25

Reactive Dependency Tracking

Fine-grained update built from dependencies recorded at read time; the amount of work the same table change produces in this family, the ledger's cost, consistency under diamond dependency, and batched writes.

Contents

The previous lesson’s work traced back to one gap: the framework did not know what changed, so it compared to find out. Knowing would remove the need to compare.

The way to know is to record where a value is read at the moment it is read. While a computation runs, the values it looks at get tracked, and each one is marked “this is what read me.” When a value is written, the list of computations needing an update is already at hand. This family turns updating from a search problem into a notification problem.

The Binding, Not the Component

In this family, the update unit is not the component. The component function runs once, setting up the reactive values and the points where the view binds to them, then never runs again. What gets updated is a single binding: a text node, an attribute, or a class name.

Three primitives are enough. A signal is a readable, writable value. A derived value is computed from other values and memoizes its result. An effect reruns when its sources change; the code that writes to the view lives here. These are the same three concepts from the Component Lifecycle and State topic — the difference is that here they bind directly to each other, not to a component.

The Same Change, Now Counted

The previous lesson’s table is rebuilt here at the same size: two hundred rows, a single cell’s value changes.

// signal.mjs — dependency recorded at read time and fine-grained update
let counter = { subscription: 0, computed: 0, effect: 0, patch: 0 };
const reset = () => (counter = { subscription: 0, computed: 0, effect: 0, patch: 0 });

let tracker = null;
const queue = new Set();
const MODE = { ordered: true };   // false: the change propagates immediately, depth-first
let batchDepth = 0;
const batch = (fn) => { batchDepth++; try { fn(); } finally { if (--batchDepth === 0) flush(); } };

const track = (source) => {
  if (!tracker) return;
  source.subscribers.add(tracker);
  tracker.level = Math.max(tracker.level, source.level + 1);
  counter.subscription++;
};
const mark = (node) => {
  if (MODE.ordered) { if (!node.dirty) { node.dirty = true; queue.add(node); } }
  else { node.dirty = true; node.run(); }
};
function flush() {
  while (queue.size) {
    let next = null;
    for (const n of queue) if (!next || n.level < next.level) next = n;   // level order
    queue.delete(next);
    if (next.dirty) next.run();
  }
}

function signal(initial) {
  const d = { value: initial, subscribers: new Set(), level: 0 };
  return {
    read() { track(d); return d.value; },
    write(v) {
      if (Object.is(v, d.value)) return;
      d.value = v;
      for (const s of [...d.subscribers]) mark(s);
      if (batchDepth === 0) flush();
    },
  };
}

function derived(fn) {
  const d = { value: undefined, subscribers: new Set(), level: 0, dirty: true };
  d.run = () => {
    const previous = tracker; tracker = d;
    counter.computed++;
    const next = fn();
    tracker = previous;
    const changed = !Object.is(next, d.value);
    d.value = next; d.dirty = false;
    if (changed) for (const s of [...d.subscribers]) mark(s);
  };
  return { read() { if (d.dirty) d.run(); track(d); return d.value; } };
}

function effect(fn) {
  const d = { subscribers: new Set(), level: 0, dirty: false };
  d.run = () => { const previous = tracker; tracker = d; counter.effect++; d.dirty = false; fn(); tracker = previous; };
  d.run();
  return d;
}

// --- 1. Two-hundred-row measurement table: each cell sets up its own binding ---
const ROW_COUNT = 200;
reset();
const doc = new Array(ROW_COUNT).fill("");
const signals = Array.from({ length: ROW_COUNT }, (_, i) => signal(-10 + i * 0.1));
for (let i = 0; i < ROW_COUNT; i++) {
  effect(() => { doc[i] = `${signals[i].read().toFixed(1)} °C`; counter.patch++; });
}
const overThreshold = derived(() => signals.filter((s) => s.read() > 5).length);
let summaryText = "";
effect(() => { summaryText = `over threshold: ${overThreshold.read()}`; counter.patch++; });
const setup = { ...counter };

reset();
signals[120].write(42.5);
const update = { ...counter };

console.log("phase       subscription  derived compute  effect run  document write");
const print = (label, s) => console.log(`${label.padEnd(11)} ${String(s.subscription).padStart(8)} ${String(s.computed).padStart(17)} ${String(s.effect).padStart(15)} ${String(s.patch).padStart(14)}`);
print("setup", setup);
print("update", update);
console.log("changed cell's text:", doc[120], "| summary:", summaryText);

// --- 2. Batched writes: two writes, one update ---
reset();
signals[10].write(60);
signals[11].write(70);
console.log("\ntwo separate writes → derived compute:", counter.computed, "| effect runs:", counter.effect, "| summary:", summaryText);
reset();
batch(() => { signals[12].write(80); signals[13].write(90); });
console.log("one batched write → derived compute:", counter.computed, "| effect runs:", counter.effect, "| summary:", summaryText);

// --- 3. Diamond dependency: two values derived from the same source, plus a third that combines them ---
const log = [];
const build = () => {
  const raw = signal(-4.2);
  const celsius = derived(() => `${raw.read().toFixed(1)} °C`);
  const fahrenheit = derived(() => `${(raw.read() * 1.8 + 32).toFixed(1)} °F`);
  const summary = derived(() => `${celsius.read()} / ${fahrenheit.read()}`);
  effect(() => log.push(summary.read()));
  return raw;
};

console.log("\n--- topologically ordered propagation ---");
MODE.ordered = true; log.length = 0; reset();
build().write(10);
console.log("effect runs:", counter.effect, "| log:", JSON.stringify(log));

console.log("\n--- immediate depth-first propagation ---");
MODE.ordered = false; log.length = 0; reset();
build().write(10);
console.log("effect runs:", counter.effect, "| log:", JSON.stringify(log));
MODE.ordered = true;
phase       subscription  derived compute  effect run  document write
setup            401                 1             201            201
update           202                 1               2              2
changed cell's text: 42.5 °C | summary: over threshold: 50

two separate writes → derived compute: 2 | effect runs: 4 | summary: over threshold: 52
one batched write → derived compute: 1 | effect runs: 3 | summary: over threshold: 54

--- topologically ordered propagation ---
effect runs: 2 | log: ["-4.2 °C / 24.4 °F","10.0 °C / 50.0 °F"]

--- immediate depth-first propagation ---
effect runs: 3 | log: ["-4.2 °C / 24.4 °F","10.0 °C / 24.4 °F","10.0 °C / 50.0 °F"]

The Two Families’ Numbers

Set the update row next to the previous lesson’s and the contrast is sharp. The virtual-tree family’s one-cell change produced 802 component calls, 1004 node comparisons, and 601 attribute comparisons. Here there is one derived recompute, two effect runs, and two document writes — the tree is never traversed, no comparison happens at all.

One write is the changed cell, the other the threshold summary — a derived value bound to all two hundred signals, so it recomputes no matter which cell changed. Aggregating computations fall outside fine-grained granularity: a value that depends on everything runs on every change. The family’s gain comes from the locality of bindings, not from a change in the data flow’s structure.

The gap widens with scale. In the virtual-tree family, the work is proportional to the row count; here it is proportional to the number of changed bindings. In a two-thousand-row table, the first number grows tenfold; the second stays the same.

The Ledger’s Cost

The setup row shows the cost: 401 subscriptions, 201 effect runs — one effect per cell, one subscription per effect, plus the summary computation subscribing to all two hundred signals at once.

This is a graph kept in memory: every signal carries a set of subscribers, every computation a list of sources. The virtual-tree family has no such structure — it keeps only the previous tree, and relationships are rederived on every comparison. Here the relationships persist and occupy memory.

The 202 subscriptions on the update row show a second cost: every time a computation runs, it records its dependencies again, since a conditional read might bind to a different source next time. The summary computation rereads all two hundred signals and resubscribes. Maintaining the ledger is itself a line item of work.

The third cost is invisible connection: where a value is read is not marked in the source text, the read itself does the recording. Placing a read in the wrong spot — moving it outside an effect, for instance — silently severs the binding, and the view does not update. Like the call-order rule for position-based hooks, this is a contract the language does not enforce.

Diamond Dependency and Consistency

The last two sections show a notification-based system’s most subtle problem.

Two values are derived from a raw measurement — Celsius and Fahrenheit — and a third combines them. Writing the raw value invalidates both derived values, but they update in sequence. Under immediate depth-first propagation, the summary updates the instant Celsius does, while Fahrenheit is still at its old value.

The second line of the log shows this: 10.0 °C / 24.4 °F. This value was never real — Celsius is new, Fahrenheit is old. This is called a glitch, and it does not just run the effect one extra time; that extra run produces a wrong picture.

This does not happen under topologically ordered propagation: every computation gets a level one higher than the highest level among its sources, and the update queue drains in level order, so a computation runs only after all its sources have updated. The log shows only the starting value and the consistent final value.

This looks like an implementation detail of the family, but it carries correctness directly. One of the questions to ask when evaluating a framework’s reactive core is whether glitches are prevented by definition.

Batched Writes

The second section shows a second consistency tool. Writing two signals separately runs the summary computation twice and fires effects four times; the same two writes inside a single batch compute the summary once and drop the effect count to three.

The gain is not just the amount of work: batching makes two changes appear as a single pass, so the in-between state that was never valid never leaks out. This is the same batching concept from the Component Lifecycle and State topic — here it is tied not to the component boundary but to an explicitly marked block.

The Family’s Profile

The update unit is the binding; granularity descends all the way to a single text node. The amount of work is proportional not to the size of the produced tree but to the number of changed bindings, and memoization is usually unnecessary — a derived value is already memoized.

In exchange, the runtime must build and maintain the dependency graph: setup costs more, memory use grows with the number of bindings, and records refresh every time a computation runs. A component function that runs once demands a different mental model than the “start over on every render” habit — a value read during setup freezes, and must be read reactively to stay tracked.

Summary

  • In this family, dependency is recorded at read time; the update unit is not the component but a single binding.
  • The same one-cell change produced 802 component calls and 1004 node comparisons in the virtual-tree family, versus one derived recompute and two document writes here.
  • Aggregating computations fall outside fine-grained granularity: a derived value bound to every signal recomputes no matter which signal changed.
  • The ledger is kept in memory and needs maintenance; every computation re-records its dependencies each time it runs.
  • Immediate depth-first propagation produces a glitch under diamond dependency; topologically ordered propagation prevents it by definition.
  • Batching both reduces the amount of work and keeps intermediate state from leaking out.

Next Step

Both families keep their ledger at runtime: one by comparing trees, the other by recording subscriptions. Both have a counterpart in the downloaded code — a diffing algorithm or a dependency-graph mechanism — that has to reach the browser the moment the page loads. Yet which expression depends on which value can, in most cases, be read straight from the source text: in a template, which variable a text node reads is written down. The next lesson covers the approach that draws this inference before the code runs, writing a transformer that resolves dependencies at compile time and leaves only update code for the runtime.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close