How Frameworks Work: Reactivity, Signals & the Virtual DOM

Open the docs for React, Vue, Svelte, Solid, Angular, or Preact and they look nothing alike. Different syntax, different mental models, different words for the same idea. But they were all built to solve one problem, and once you see it, every framework starts to read the same way.

Here is the problem. Your app has state — a count, a list of todos, a logged-in user. And it has a view — the actual DOM the user looks at. The view is supposed to reflect the state. When the count changes, the number on screen must change with it. Nobody wants to look at a “3” when the real count is 4.

Keeping those two things in agreement, forever, as state changes in a hundred places, is the entire job. Everything a framework does — virtual DOM, signals, components, hooks, runes — is machinery for doing it without you hand-writing the update every single time.

The vanilla baseline: doing it by hand

Before a framework, you keep the DOM in sync yourself. You read the state, you find the node, you write the new value. Here is a counter with three views of the same number:

let count = 0;

const label = document.querySelector("#label");
const doubled = document.querySelector("#doubled");
const bar = document.querySelector("#bar");

document.querySelector("#inc").addEventListener("click", () => {
  count += 1;
  // now update every place that shows count — by hand
  label.textContent = count;
  doubled.textContent = count * 2;
  bar.style.width = count * 10 + "%";
});

For one counter this is completely fine. Honestly, for a small widget, reach for exactly this — no framework needed. The trouble starts when count is read in twenty places, changed from five different handlers, and derived into other values that are themselves shown on screen. Every one of those becomes a line you have to remember to write. Forget one and the “3”-when-it-should-be-“4” bug ships.

This is imperative UI code: you spell out each DOM operation, step by step, in the order it must happen. The bug surface grows with the number of connections between state and view, and that number grows fast.

imperativecount += 1label.textdoubled.textbar.widthtitle.textbadge.textdeclarativecount = 4UI(state)frameworkapplies diffyou write one lineit updates the rest
Imperative code wires every state change to every affected DOM node by hand — one arrow per line you have to write and maintain. Declarative code writes state once; a single UI function derives the whole view, and the framework applies the difference.

Declarative UI: the view is a function of state

The declarative idea is small and it changes everything. Instead of writing DOM operations, you write a function that, given the current state, describes what the UI should look like. You never touch the DOM yourself. When state changes, you hand the framework the new state and it figures out which DOM operations are needed to match.

Written as an equation, the whole field fits on one line:

view = f(state)

Your job shrinks to two things: describe f, and change state. The framework owns the gap between them — the actual querySelector/textContent/appendChild calls you used to write by hand. Here is the same counter, declarative in spirit:

// You describe the view for ANY count. You never write label.textContent.
function View(count) {
  return `
    <span>${count}</span>
    <span>${count * 2}</span>
    <div style="width:${count * 10}%"></div>
  `;
}

setCount(4); // change state — the framework re-derives and patches the DOM

Every framework is a different answer to one follow-up question: when state changes, how do you find the minimal set of DOM operations to apply? Re-running f gives you a fresh description of the whole UI, but blindly wiping the DOM and rebuilding it would be slow and would throw away focus, scroll position, and input state. So each framework has a strategy. There are three big ones.

See the difference: five hand-writes vs one

Before the strategies, feel the problem. Both counters below are identical on screen. The left one is imperative — the click handler updates each view by hand. The right one is reactive — the handler changes one value and the derived views update themselves. Watch the “DOM writes you wrote” tally.

interactiveImperative vs reactive: the same counter, two amounts of work

The screens stay identical, but the tallies diverge: three hand-written writes per click on the left, one on the right. Add a fourth view of count and the imperative side needs a fourth line in every handler; the reactive side needs nothing — you just read count in a new place. That reactive core on the right is about fifteen lines, and it is the heart of the second strategy. Hold that thought.

Strategy 1 — Virtual DOM and diffing (React’s approach)

The virtual DOM was React’s answer, and it is still how React works in 2026. The idea: don’t touch the real DOM directly. When state changes, re-run your component to produce a lightweight JavaScript description of the whole UI — a tree of plain objects, one per element. That tree is the “virtual DOM.” Then compare the new tree against the previous one, node by node, find the differences, and apply only those to the real DOM.

That comparison step is called reconciliation (or diffing). A virtual node is just an object — roughly &#123; type: "span", props: &#123;...&#125;, children: [...] &#125;. Building and comparing objects is far cheaper than reading and writing real DOM nodes, so the strategy trades a bit of memory and CPU for a big cut in expensive DOM work.

old virtual treeullilicount: 3savednew virtual treeullilicount: 4saveddiff: 1 text node changedpatchreal DOMli · saved (untouched)li · count: 4 (updated)
On state change, React re-renders to a new virtual tree, diffs it against the previous one, and finds that only one text node differs. That single patch — not a full rebuild — is what touches the real DOM.

The upside is a genuinely nice programming model. You write your component as if it renders from scratch every time — view = f(state) in its purest form — and never think about surgical updates. The cost is real, though: on every change React re-runs components and walks a tree to discover what you changed, even when you already knew. For most apps that overhead is invisible. For big lists and high-frequency updates, it is the thing you end up optimizing around.

React’s newer React Compiler (opt-in for production as of React 19) softens this by auto-inserting memoization so components skip re-rendering when their inputs did not change. Worth being precise about what it does: it makes React smarter about whether to re-run a component. It does not remove the virtual DOM or reconciliation — when a component does re-render, the diff still happens. The mechanism is unchanged; the compiler just calls it less often.

Strategy 2 — Fine-grained reactivity and signals

Signals attack the same problem from the opposite end. Instead of re-rendering everything and diffing to discover what changed, track what changed as it happens, and update exactly the DOM nodes that depend on it. No tree walk, no diff, because the framework already knows the wiring.

The trick is dependency tracking, and you already saw it work in the playground above. A signal is a value with a getter and a setter. When a piece of UI (an effect) reads a signal through its getter, the signal records “this effect depends on me.” When the signal’s setter runs, it re-runs precisely those effects — nobody else. The connection graph builds itself automatically from the reads.

signalsderivedeffects (DOM)celsius = 21changedname = “Ada”unchangedfahrenheitc * 9/5 + 32show celsiusshow fahrenheitshow name
A signal graph wires itself from reads. Change celsius and the update flows only down its dependency edges — fahrenheit recomputes, the two text effects run — while the unrelated 'name' effect is never touched.

Because the graph knows that show name never reads celsius, changing the temperature cannot touch it. There is no diff to run and no component to re-execute — the setter walks its own edges and stops. That is what “fine-grained” means: the granularity of an update is a single DOM node, not a component or a tree.

Build the engine yourself

The reason signals feel like magic is that the whole core is tiny. Here is a working reactive engine in about twenty lines. It leans entirely on closures: each signal closes over its own value and its own set of subscribers, and effect closes over the currently-running function so getters know who to subscribe.

let listener = null; // the effect currently running, if any

function signal(value) {
  const subscribers = new Set();
  return {
    get() {
      if (listener) subscribers.add(listener); // record the dependency
      return value;
    },
    set(next) {
      value = next;
      subscribers.forEach((fn) => fn()); // re-run only dependents
    },
  };
}

function effect(fn) {
  const run = () => {
    listener = run;   // "I am reading now — subscribe me"
    fn();
    listener = null;  // stop tracking
  };
  run(); // run once to register dependencies
}

function derived(compute) {
  const s = signal();
  effect(() => s.set(compute())); // recompute when its inputs change
  return { get: s.get };
}

That is the same core from the playground, with derived added. It is not a toy version of the idea — it is the idea. Production signal libraries add batching (so a burst of set calls flushes once), glitch-free ordering, cleanup for removed effects, and lazy evaluation, but the getter-subscribes / setter-notifies loop is exactly this. Extend it in the demo below.

interactiveA ~20-line signal engine driving a live thermostat

This is why signals came roaring back. Vue’s ref(), Solid’s createSignal, Angular’s signal(), Preact’s signals, and Svelte 5’s runes are all this pattern with different spellings. There is even a TC39 proposal to add signals to the language itself — as of mid-2026 it is still early-stage with a preview polyfill, not something to ship yet, but the fact that it exists tells you how thoroughly the model has won mindshare.

Strategy 3 — Compile the framework away

The third strategy asks a sharp question: if a compiler can read your component at build time, why ship a diffing engine to the browser at all? Svelte pioneered this. Its compiler analyzes your component’s markup and reactive statements ahead of time and emits plain JavaScript that pokes the exact DOM nodes involved. There is no virtual DOM in the output and only a thin runtime — the framework, in large part, compiled itself away.

component sourcelet count = $state(0)<button onclick={inc}> clicks: {count}</button>declarative, readablecompilerbuild timeemitted DOM codeconst t = text(node, count);btn.onclick = () => { count++; t.data = count; // 1 node};no vdom, no diff shippedyou write the left · the browser runs the right
A compiled framework analyzes the component at build time and emits direct DOM instructions. What ships to the browser is your logic plus a thin runtime — not a diffing engine that must rediscover changes at runtime.

Modern Svelte (version 5, with its runes API) blends this compiler approach with signals: $state, $derived, and $effect are the signal / derived / effect trio you just built, except the compiler wires the dependency graph at build time instead of at runtime. The line between “compiled” and “signals” has genuinely blurred. Vue’s Vapor Mode, stabilizing through the 3.6 line in 2026, does the same — compiling components straight to imperative DOM operations on top of its signal system, skipping the virtual DOM entirely for opted-in components.

The vocabulary every framework shares

Strip away the syntax and the same handful of primitives show up everywhere. Learn these four and framework docs stop looking foreign:

  • State / signal — a value the framework watches. React useState, Vue ref, Solid createSignal, Svelte $state, Angular signal. Change it and the UI reacts.
  • Derived / computed / memo — a value calculated from other reactive values, recomputed automatically when they change. React useMemo, Vue computed, Solid/Svelte derived. Never store what you can derive.
  • Effect — a side effect that runs when its reactive inputs change: write to the DOM, log, fetch. React useEffect, Vue watchEffect, Solid/Svelte effect.
  • Component — a reusable function that takes inputs (props) and returns a piece of UI. Composition happens by nesting components, which brings us to data flow.

Props and one-way data flow

Components receive data through props — inputs passed down from a parent, like function arguments. The near-universal rule is one-way data flow: data flows down from parent to child, and a child that wants to change something a parent owns sends an event or callback up rather than reaching over and mutating the parent’s state. Keeping the direction consistent is what makes a large UI tractable — when a value is wrong, you look up the tree toward its single source, not sideways into a web of mutations.

Appowns state: cartHeaderprop: countProductRowprop: itemprops downevent up: add()
One-way data flow: state lives at a source, props flow down to children as read-only inputs, and changes travel back up as events. Data never flows sideways or gets mutated by a child directly.

Why keys matter in lists

When you render a list and it changes — an item is inserted, removed, or reordered — the framework has to match each new item to the DOM node that already represents it. Without help, it matches by position, which goes wrong the moment order changes: insert at the top and every node below looks “changed,” so the framework rebuilds them, losing focus, animations, and input state along the way.

A key is a stable identity you attach to each item so the framework can track it across renders regardless of position. Use the item’s real id, never the array index (the index is the position, so it defeats the purpose). This matters whether the framework diffs a virtual DOM or updates signal-backed nodes — both need to know which item is which.

// Bad: index as key — reorder and identities shuffle, DOM state leaks between rows
{todos.map((todo, i) => <Row key={i} todo={todo} />)}

// Good: stable id — each row keeps its DOM node, focus, and animation across moves
{todos.map((todo) => <Row key={todo.id} todo={todo} />)}

Now you can read any of them

These strategies are not walls between frameworks — they are converging. React leans on its compiler to cut needless renders; Vue and Svelte compile toward direct DOM code; Solid, Angular, Preact, and Svelte all sit on signals; the browser vendors are eyeing native signals for later this decade. Pick the one whose ergonomics you like. Underneath, they are all solving view = f(state), and they differ mainly in when and how they find the smallest DOM edit.

That is also why chasing raw benchmark numbers rarely pays off for real apps. The gaps between mainstream frameworks are small next to the cost of your own render logic, your bundle size, and how well you avoid needless work — the fundamentals covered in Core Web Vitals and build tooling move the needle far more than the diff-vs-signal choice. Learn the model once and the framework becomes an implementation detail.

Summary

  • Every UI framework exists to keep the DOM in sync with your state so you don’t hand-write DOM updates. The model is view = f(state): describe the UI, change the state, let the framework apply the difference.
  • Imperative code (querySelector + textContent by hand) is fine for small widgets but the bug surface grows with every connection between state and view. Declarative UI moves that work to the framework.
  • Virtual DOM (React): re-render to a lightweight tree, diff against the previous one, patch the minimal real-DOM changes. React Compiler (opt-in, React 19) skips needless re-renders but keeps the diff.
  • Signals / fine-grained reactivity (Solid, Vue, Angular, Svelte 5, Preact): getters record dependencies, setters re-run only the dependents. No diff, no full re-render — updates hit individual DOM nodes. The whole engine is ~20 lines built on closures.
  • Compiled (Svelte, Vue Vapor): a build-time compiler emits direct DOM instructions and ships a thin runtime instead of a diffing engine. Modern versions fuse this with signals.
  • Shared vocabulary: state/signal, derived/computed, effect, component. Data flows one way — props down, events up — and lists need stable keys (never the array index) so identity survives reordering.
  • Signals resurged in 2026 because they update precisely with little overhead; there is even an early TC39 proposal to put them in the language. Learn the primitives once and every framework reads the same.