Internals: The reactive core

Internals

The reactive core

There is no virtual DOM. A signal knows what read it, and a write tells exactly those things to re-run. The compiler's job is to make the read and the write meet in the right place; the runtime's job is the bookkeeping.

The shape of it

const count = createSignal(0);
const doubled = createMemo(() => count() * 2);
createEffect(() => render(count(), doubled()));
  • a signal holds a value and remembers its readers
  • a memo is a signal computed from other signals; it only recomputes when they change
  • an effect re-runs when anything it read changes

count() inside the memo subscribes the memo. count() inside the effect subscribes the effect. A write to count marks both dirty, and only those two.

Tracking

Reading a signal inside a tracked computation subscribes it. Reading it outside one is just a read — no subscription, no update. That is the entire meaning of "tracked":

tracking();            // am I inside a tracked computation?
untrack(() => count()); // read without subscribing

untrack is how you deliberately read a value without being subscribed to it — a read for logging, a comparison, a value passed to a library that will not trigger anything.

Batching

A write does not update anything immediately. It marks subscribers dirty and schedules a flush:

batch(() => {
	a.set(1);
	b.set(2);
	c.set(3);
});

Without batch, three writes mean three rounds of updates. With it, one. This matters for anything that sets several signals in a row — a form reset, a swap between objects, a multi-field edit. The flush is a microtask, not a frame, so it happens before the browser paints and a user never sees a half-applied state.

That microtask boundary is also what makes derived values correct. Two writes in sequence produce one recomputation, not two, because the second sees the first's final value.

Effects

Effects run after the update, never during it, so an effect that writes a signal it also reads cannot loop synchronously:

const dispose = createEffect(() => {
	// re-runs when count changes, after the DOM has been updated
});

The cleanup returned by an effect runs before the next run and again on dispose. createEffectPre runs before DOM updates when you need to measure something first. flushDeferredEffects() forces pending work to happen now instead of at the microtask.

createRoot and withEffectScope exist so a whole tree of effects can be torn down together — the router uses them to dispose a page's effects when navigating away.

Why this is fast

A virtual DOM re-renders a component and diffs its output. Here, count++ runs the one update function that read count, and that function touches the one text node whose value changed. The work is proportional to what changed, not to the size of the tree, and it is not proportional to the number of components either — a component that never read count is not involved at all.

The cost of that is that the mapping from "state" to "node" has to be established at compile time. A read the compiler cannot see — hidden inside a plain function that receives the value as an argument — is a read with no subscription, which is the most common source of "why doesn't this update?".

Next

Edit this page