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

- Published: 2026-07-22
- Tags: javascript, frameworks
- Source: https://jsledger.com/blog/signals-are-not-magic/
- Language: en-US
- Author: Jonah Vail

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

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

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

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

:::note
"Push the invalidation, pull the value" is the design. Pure push produces
glitches; pure pull cannot know when to start. Every production implementation
is a hybrid, and the differences between them are mostly in the details of the
staleness marking.
:::

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](/blog/the-event-loop-you-think-you-know/) 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](/blog/how-v8-decides-to-optimize-your-function/), 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`](https://github.com/solidjs/solid/blob/main/packages/solid/src/reactive/signal.ts),
[Vue's `effect.ts`](https://github.com/vuejs/core/blob/main/packages/reactivity/src/effect.ts)
and [Preact Signals](https://github.com/preactjs/signals) are each a few hundred
lines of readable source, and [Svelte's runes
documentation](https://svelte.dev/docs/svelte/what-are-runes) is explicit that
`$state` compiles to the same machinery. Reading one of them is faster than
reading an article about them — including this one.
