FrontendPro

123Implement a reactive dependency system with automatic dependency tracking

hardAbout 75 minutesCodingFreereactivitysignalsdependency-trackingobservers

Problem

Signals (Solid, Vue, Preact Signals, Angular) update exactly what depends on changed data, with no dependency arrays. Build the core:

  • createSignal(initial) returns [read, write]. write(value | updater) changes the value; writing an equal value (Object.is) does nothing.
  • createMemo(fn) returns a read function for a derived value. It is computed lazily, cached, and recomputed only when one of the signals or memos it read has changed.
  • createEffect(fn) runs fn immediately and again whenever anything it read during its last run changes. If fn returns a function, call it before the next run and on dispose. createEffect returns a dispose function.
  • batch(fn): run fn; effects affected by writes inside it run once, after it.

Dependencies are tracked automatically: whatever a memo or effect reads while it runs becomes its dependency set, rebuilt on every run (so if/else branches work).

Glitch-free: in a diamond (a → b, a → c, effect reads b and c), changing a runs the effect once, and it never sees b updated but c stale. If a memo recomputes to the same value, effects that depend only on it do not re-run.

const [price, setPrice] = createSignal(100);
const [qty, setQty] = createSignal(2);
const total = createMemo(() => price() * qty());
createEffect(() => console.log(`Total: ${total()}`)); // "Total: 200"
batch(() => {
  setPrice(120);
  setQty(3);
}); // logs "Total: 360" once

Try it yourself

Write your solution, run it, then submit to check it against the tests.

solution.jsJavaScript

Press Run or ⌘ Enter to execute your code.

Step by step

Build the solution one idea at a time.

  1. 1

    Track who is reading

    Keep a global currentObserver. While a memo or effect runs, it is the current observer; every signal or memo read registers itself as a source of the observer, and the observer as a subscriber of the source. That is automatic dependency tracking.

  2. 2

    Re-track on every run

    Before running an observer again, unsubscribe it from all its old sources and start a fresh source list. Dependencies that are no longer read (the other branch of an if) disappear automatically.

  3. 3

    Versions instead of booleans

    Every signal and memo has a version that increases when its value really changes. An observer records the version of each source it read. Later, "did anything change?" means "is any source's version different from what I recorded?"

  4. 4

    Push dirtiness, pull values

    When a signal changes, walk its subscribers: memos are marked dirty (and their subscribers walked in turn), effects are added to a set of pending effects. Nothing is recomputed yet. When pending effects run, each one first asks its sources for their current version; a dirty memo recomputes at that moment (pull). Because every memo is up to date before the effect runs, the effect sees a consistent state, and it runs once even if several of its sources changed.

  5. 5

    Skip effects whose inputs did not really change

    If a memo recomputes to an equal value, its version stays the same, so an effect that only depends on it sees no change and is skipped.

  6. 6

    Batch

    batch increments a depth counter; writes inside it only collect pending effects. When the outermost batch ends, flush them.

Approach

A dependency graph built at run time: reads register edges, writes push "dirty" marks along them, and effects pull fresh values when they run. Version numbers make the graph glitch-free and let unchanged memos stop propagation.

Solution

let currentObserver = null;
let batchDepth = 0;
let flushing = false;
const pendingEffects = new Set();

function track(source) {
  if (!currentObserver) return;
  currentObserver.sources.set(source, source.version);
  source.observers.add(currentObserver);
}

function unsubscribeAll(observer) {
  for (const source of observer.sources.keys()) source.observers.delete(observer);
  observer.sources = new Map();
}

function markDirty(source) {
  for (const observer of source.observers) {
    if (observer.kind === "memo") {
      if (!observer.dirty) {
        observer.dirty = true;
        markDirty(observer);
      }
    } else {
      pendingEffects.add(observer);
    }
  }
}

function runInContext(observer, fn) {
  unsubscribeAll(observer);
  const previous = currentObserver;
  currentObserver = observer;
  try {
    return fn();
  } finally {
    currentObserver = previous;
  }
}

function recompute(memo) {
  const value = runInContext(memo, memo.fn);
  memo.dirty = false;
  if (!memo.initialized || !Object.is(value, memo.value)) {
    memo.value = value;
    memo.version++;
    memo.initialized = true;
  }
}

function changed(observer) {
  for (const [source, seen] of observer.sources) {
    if (source.kind === "memo" && source.dirty) recompute(source);
    if (source.version !== seen) return true;
  }
  return false;
}

function runEffect(effect) {
  effect.cleanup?.();
  const result = runInContext(effect, effect.fn);
  effect.cleanup = typeof result === "function" ? result : undefined;
}

function flush() {
  if (batchDepth > 0 || flushing) return;
  flushing = true;
  try {
    while (pendingEffects.size > 0) {
      const effects = [...pendingEffects];
      pendingEffects.clear();
      for (const effect of effects) {
        if (!effect.disposed && changed(effect)) runEffect(effect);
      }
    }
  } finally {
    flushing = false;
  }
}

function createSignal(initial) {
  const node = { kind: "signal", value: initial, version: 0, observers: new Set() };
  const read = () => {
    track(node);
    return node.value;
  };
  const write = (next) => {
    const value = typeof next === "function" ? next(node.value) : next;
    if (Object.is(value, node.value)) return;
    node.value = value;
    node.version++;
    markDirty(node);
    flush();
  };
  return [read, write];
}

function createMemo(fn) {
  const memo = { kind: "memo", fn, value: undefined, version: 0, dirty: true, initialized: false, sources: new Map(), observers: new Set() };
  return () => {
    if (memo.dirty) recompute(memo);
    track(memo);
    return memo.value;
  };
}

function createEffect(fn) {
  const effect = { kind: "effect", fn, sources: new Map(), cleanup: undefined, disposed: false };
  runEffect(effect);
  return () => {
    effect.disposed = true;
    effect.cleanup?.();
    unsubscribeAll(effect);
  };
}

function batch(fn) {
  batchDepth++;
  try {
    return fn();
  } finally {
    batchDepth--;
    flush();
  }
}

Complexity

Time: a write is O(affected nodes) to mark dirty; each affected memo recomputes at most once per flush, and each affected effect runs at most once. Space: O(edges) in the dependency graph.

Edge cases

  • Writing the same value triggers nothing.
  • A memo is not computed until someone reads it, and then only when a source changed.
  • Branching effects stop depending on signals they no longer read.
  • A diamond runs the effect once with consistent values.
  • Effects that write signals trigger further effects in the same flush, without recursion.

Follow-up questions

Answer each one out loud first, then reveal the model answer.