JS Ledger

Signals Are Not Magic: The Dependency Graph Underneath

Every signals library is the same forty lines of bookkeeping. Here is what those lines do, and where the four popular implementations diverge.

5 min read
A cast-iron linkage machine on a worn wooden bench: connecting rods driving a train of meshed steel gears, with one brass coupling at the joint where the rods meet.

Signals get described as magic often enough that it is worth saying plainly: there is no magic in them. The core of every implementation shipping today — Solid, Vue’s reactivity core, Svelte 5’s runes, Preact Signals — is the same piece of bookkeeping, and it fits on one screen.

What differs between them is where that bookkeeping lives, what it costs, and what happens when the graph gets a shape more complicated than a list.

Everything below describes the implementations as they stand in Solid 1.9, Vue 3.5, Svelte 5 and Preact Signals 2 — the reactive cores of all four have been stable in shape for a while, and the source links at the end are the check.

The forty lines

Start with the smallest thing that works.

let currentEffect = null;

function signal(value) {
	const subscribers = new Set();

	return {
		get() {
			if (currentEffect) {
				subscribers.add(currentEffect);
				currentEffect.dependencies.add(subscribers);
			}
			return value;
		},
		set(next) {
			if (Object.is(next, value)) return;
			value = next;
			for (const subscriber of [...subscribers]) subscriber.run();
		},
	};
}

function effect(fn) {
	const running = {
		dependencies: new Set(),
		run() {
			// Drop the previous subscriptions: a conditional branch may mean this
			// run reads a different set of signals than the last one did.
			for (const dependency of running.dependencies) dependency.delete(running);
			running.dependencies.clear();

			const previous = currentEffect;
			currentEffect = running;
			try {
				fn();
			} finally {
				currentEffect = previous;
			}
		},
	};

	running.run();
}

That is auto-tracking in full. There is exactly one trick in it, and it is currentEffect: a module-level pointer to whichever effect is executing right now. A signal’s getter does not know who is reading it — it asks the global.

This is why signals track dependencies without a dependency array, and it is also why the tracking has a hard boundary. currentEffect is set for the synchronous duration of fn(). Read a signal after an await, and the pointer has already been restored: the read is invisible and the effect will not re-run when that value changes.

effect(async () => {
	console.log(count.get());   // tracked
	await fetch('/api/x');
	console.log(other.get());   // NOT tracked — currentEffect is null again
});

Every implementation has some version of this rule. It is the single most common source of “my signal is not reactive” bug reports, and it is not a bug.

Why naive propagation is wrong

The version above pushes eagerly: set walks the subscriber list and runs each effect immediately. That is fine for a flat list and wrong for a diamond.

const a = signal(1);
const b = computed(() => a.get() + 1);
const c = computed(() => a.get() * 2);
const d = computed(() => b.get() + c.get());

Set a to 2. Eager push notifies b, which notifies d, which recomputes using the old c. Then c updates and notifies d again. d ran twice, and the first run produced a value that was never mathematically valid — b from the new a, c from the old one.

That intermediate wrong value is called a glitch, and avoiding it is most of what separates a real implementation from the forty lines above.

The standard answer is to split propagation in two. On write, walk the graph and mark every dependent stale — cheap, no user code runs. Then, when something actually needs a value, pull: a stale node checks whether its own dependencies changed, recomputes only if they did, and caches. d is pulled once, pulls b and c, and both are already settled by then.

Effects are the exception: something has to schedule them, since nobody pulls a side effect. Implementations queue them and flush after the graph settles — which is why an effect in Vue or Solid does not run synchronously inside set. The flush itself usually rides the microtask queue, so the timing follows the event loop’s rules rather than the framework’s.

Where the four differ

Solid puts the graph at the centre of the framework. Components run once; the reactive graph is what updates the DOM afterwards, node by node. There is no virtual DOM to diff, so a signal update touches exactly the text nodes and attributes that read it. The cost is that the component boundary stops being a render unit, which is a genuine mental adjustment coming from React.

Vue builds the same graph but populates it through Proxy. Reading state.user.name triggers a get trap that tracks the property, so ordinary object syntax participates without explicit .get() calls. The trade is that the proxy wraps deeply and identity is not preserved — state.user === rawUser is false, which surprises people passing reactive objects to non-Vue code.

Svelte 5 compiles the bookkeeping away. $state and $derived are not function calls at runtime; the compiler rewrites reads and writes into signal operations. The developer experience is plain variable assignment, and the runtime graph is the same one described above.

Preact Signals ships the graph as a standalone library and bolts it onto a virtual-DOM framework. Reading a signal inside a component subscribes that component; reading it in JSX text position can bypass the component render entirely and update the text node directly.

React is the outlier, and deliberately. It has no dependency graph: it re-runs components and reconciles the output. The React Compiler memoizes that work automatically, but it is inferring what to skip, not tracking what was read. Both approaches are defensible; they are answers to different questions.

What this means for your code

Three things follow directly from the machinery.

Reads must be synchronous inside the tracking scope. Everything after an await is untracked, so capture what you need before the first one.

Fine-grained does not mean free. Every signal is a Set of subscribers and every computed is a graph node. Ten thousand of them cost ten thousand allocations, and a coarser signal holding an array is sometimes the right call. Consistent construction pays off here for the same reason it pays off everywhere: the engine specializes on object shape, and graph nodes built two different ways are two different shapes.

Equality decides everything. Object.is in the setter above means an object mutated in place never notifies anybody, because the reference did not change. Vue’s proxies dodge this by tracking at the property level; Solid and Preact expect you to replace rather than mutate.

The graph is not hidden, and it repays being looked at. Solid’s signal.ts, Vue’s effect.ts and Preact Signals are each a few hundred lines of readable source, and Svelte’s runes documentation is explicit that $state compiles to the same machinery. Reading one of them is faster than reading an article about them — including this one.

Share

Related posts

Arrow keys to move, Enter to open.