---
title: 'Reactive Dependency Tracking'
source: 'https://academia.sh/en/courses/component-based-development/reactive-dependency-tracking'
course: 'Component-Based Interface Development'
language: en
updated: '2026-08-17T18:11:04+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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