123Implement a reactive dependency system with automatic dependency tracking
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 areadfunction 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)runsfnimmediately and again whenever anything it read during its last run changes. Iffnreturns a function, call it before the next run and on dispose.createEffectreturns adisposefunction.batch(fn): runfn; 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" onceTry it yourself
Write your solution, run it, then submit to check it against the tests.
Press Run or ⌘ Enter to execute your code.
Step by step
Build the solution one idea at a time.
- 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
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
Versions instead of booleans
Every signal and memo has a
versionthat 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
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
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
Batch
batchincrements 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.