
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.