Area: How big systems work
Related: Web Workers, ResizeObserver and MutationObserver, Page Lifecycle, Markdown, Sandboxed iframes
What is it?
React, Vue, Svelte, Solid, Angular, Preact and Qwik look different, but they’re all solving the same problem: keep the DOM in sync with your data, without you writing the DOM updates by hand.
You describe what the screen should look like for a given state. The framework works out which DOM changes to make when the state changes. Everything else (components, hooks, signals, compilers, hydration) is a different answer to “how do we find out what changed, and how cheaply can we update it?”.
UI = f(state). You write
f. The framework re-runs as little of it as possible and patches the DOM with the difference.
Every framework has roughly the same parts:
- A way to describe UI: JSX, templates or a compiled component language.
- Components: functions or objects that bundle markup, state and behaviour.
- Reactivity: knowing when state changed and what depends on it.
- Rendering and reconciliation: turning the description into minimal DOM operations.
- Scheduling: batching changes and deciding when to apply them.
- Effects and lifecycle: running code after render, cleaning up.
- Events: wiring user input back into state.
- Data flow: props down, events up, plus a way to share state (context, stores).
- Server rendering and hydration: HTML first, then make it interactive.
- A compiler / build step that ties it together.
This note builds a tiny version of the core pieces and explains how the real frameworks differ. Code was run in Node 22 with linkedom as a fake DOM.
Why it exists
Before frameworks you’d write something like this:
let count = 0;
button.addEventListener('click', () => {
count++;
label.textContent = `Clicked ${count} times`;
if (count > 10) warning.hidden = false;
badge.textContent = String(count); // ← easy to forget this one
});That’s fine for one counter. In a real app, one piece of state feeds ten places on screen, and one place depends on five pieces of state. Every event handler has to know every DOM node that might need updating. Miss one and the UI shows stale data. jQuery-era apps were full of these bugs.
The alternative is to re-render everything from state on each change: app.innerHTML = template(state). Always correct. But it throws away focus, scroll position, text selection, typed input and running CSS transitions, and it’s slow.
Frameworks give you the programming model of “re-render everything” with the cost of “update only what changed”. React (2013) popularised doing this with a virtual DOM. Later frameworks found cheaper ways to get there.
1. Describing UI
Three styles, all compiled to the same thing underneath: function calls that build a tree or update the DOM.
JSX (React, Preact, Solid) is syntax sugar for function calls. The compiler turns
<button onClick={inc}>Clicked {count} times</button>into something like
jsx('button', { onClick: inc, children: ['Clicked ', count, ' times'] })React 17 introduced this automatic jsx() runtime. Before that it was React.createElement, which is why old files needed import React.
Templates (Vue, Angular, Svelte) are HTML with directives: v-if, @if, {#each}. They’re more restricted than JSX, and that’s the point. The compiler can see which parts are static and which are dynamic, and generate code that skips the static parts entirely.
Compiled components (Svelte, Solid) go further: there’s no runtime tree at all. The compiler outputs code that creates DOM nodes once and then updates exactly the text node or attribute that depends on each piece of state.
2. Components
A component is a reusable function from inputs (props) to UI, with its own state and lifecycle.
The big split is what runs again when state changes:
- React: the whole component function re-runs on every update. Hooks (
useState,useMemo) are how state survives between runs. They’re stored in a list on the component’s fiber, indexed by call order. That’s why hooks can’t be insideifstatements: the order would change and state would end up in the wrong slot. - Solid, Svelte 5, Vue’s
setup(): the component function runs once. It sets up reactive bindings, and later only those bindings run. There are no “re-renders” of the component.
That one difference explains most of the API differences between React and everything else.
3. Reactivity: how does it know something changed?
There are four approaches, in roughly historical order.
Dirty checking (AngularJS)
After every event, walk all watched expressions and compare them to their last values. Repeat until nothing changes (the “digest loop”). Simple, but cost grows with the number of bindings, not the number of changes. Angular 2+ kept a refined version (zone.js patches async APIs to know when to check), and is now moving to signals.
Explicit set + re-render (React)
State only changes through a setter: setCount(c => c + 1). The setter marks the component dirty and schedules a re-render. React re-runs the component and its children, producing a new virtual tree, and diffs it against the old one.
React doesn’t track which data your component read. It re-runs the component and lets the diff figure out what changed. That’s why memo, useMemo and useCallback exist: they’re ways to skip re-running parts that you know haven’t changed. (The React Compiler inserts that memoisation automatically at build time.)
Proxies (Vue 3, MobX)
Wrap state in a Proxy. The get trap records “this effect read user.name”. The set trap re-runs everything that read it. You write normal mutations (user.name = 'Ada'), and dependencies are tracked automatically.
Signals (Solid, Preact Signals, Angular signals, Svelte 5 runes, Vue refs)
A signal is a value with a list of subscribers. Reading it inside an effect subscribes that effect. Writing it re-runs the subscribers. This is the same idea as Vue’s proxies, one value at a time, and it’s what most frameworks have converged on.
Here’s a complete working implementation:
let current: Effect | null = null; // the effect that's running right now
function signal<T>(value: T) {
const subs = new Set<Effect>();
return {
get() {
if (current) { subs.add(current); current.deps.add(subs); } // track
return value;
},
set(v: T) {
if (Object.is(v, value)) return;
value = v;
for (const e of [...subs]) schedule(e); // trigger
},
};
}
function effect(fn: () => void) {
const e = { fn, deps: new Set<Set<Effect>>() };
run(e);
return e;
}
function run(e) {
for (const d of e.deps) d.delete(e); // drop old subscriptions...
e.deps.clear();
const prev = current; current = e;
try { e.fn(); } finally { current = prev; } // ...and re-collect them on this run
}
// batch: many writes in one tick → one re-run per effect
const queue = new Set<Effect>(); let flushing = false;
function schedule(e) {
queue.add(e);
if (!flushing) {
flushing = true;
queueMicrotask(() => { for (const e of queue) { queue.delete(e); run(e); } flushing = false; });
}
}
function computed<T>(fn: () => T) {
const s = signal<T>(undefined as T);
effect(() => s.set(fn()));
return { get: s.get };
}Test it:
const first = signal('Ada'), last = signal('Lovelace'), showFull = signal(true), count = signal(0);
const full = computed(() => `${first.get()} ${last.get()}`);
effect(() => console.log('name ->', showFull.get() ? full.get() : first.get()));
effect(() => console.log('count ->', count.get()));
count.set(1); count.set(2); count.set(3);
showFull.set(false);
last.set('Byron');Real output and what it shows:
name -> Ada Lovelace initial run
count -> 0 initial run
count -> 3 three sets, ONE re-run (batched in a microtask)
name -> Ada showFull is false, so full was not read this time
last.set('Byron'): name effect did NOT re-runThe last line is the clever bit. Because run clears and re-collects dependencies on every run, the name effect stopped depending on last once it stopped reading it. Dependencies are dynamic: whatever you actually read last time.
Real libraries add more on top: computed values that are lazy and cached instead of eager, glitch-free ordering (so an effect never sees full updated but first stale), cleanup functions, ownership trees for disposal, and protection against infinite loops.
4. Rendering and reconciliation
Virtual DOM (React, Vue, Preact)
Render produces a tree of plain objects: { type: 'li', props: { key: 'b' }, children: [...] }. On update, render again and diff the new tree against the old one:
- Same
typeat the same position → keep the DOM node, update changed props, recurse into children. - Different
type→ throw away the subtree, create a new one. - Lists → match children by key.
Comparing two arbitrary trees optimally is O(n³). Frameworks use these heuristics to get O(n), accepting that moving a subtree under a different parent recreates it.
My toy version (about 60 lines) does exactly this. On a component with a button and a 5-item list:
initial render: 21 DOM operations (every node created)
3 setState calls in one tick: 1 re-render, 2 DOM operationsThe 2 operations were the text node changing from 0 to 3, and re-binding the click listener, because the onClick arrow function is a new function on every render. React avoids that second one with event delegation (see Events below).
Why keys matter
Remove the first item from [a, b, c, d, e], where each row has an <input>. Pretend the user had typed a draft into row b’s input. Real output from my toy:
keyed b:my draft for b c:- d:- e:-
unkeyed b:- c:my draft for b d:- e:-Without keys, the diff matches by position. Row 0’s old DOM node (a) gets patched to show b, row 1’s node (b, holding the draft) gets patched to show c, and so on. The last node is deleted. The text labels look right, but DOM state that the framework doesn’t control (typed input, focus, scroll, a playing video, component state) is now attached to the wrong row. It also took 14 DOM operations instead of 2.
With keys, the diff sees that a is gone and b to e are the same items. One removal, nothing else touched.
That’s why you never use the array index as a key for lists that can reorder, insert or delete. It’s the same as having no key.
Compiled fine-grained updates (Svelte, Solid)
No tree, no diff. The compiler knows {count} lives in one text node, so it generates code like:
const text = document.createTextNode('');
effect(() => { text.data = String(count.get()); });A change to count runs one line. The cost of an update depends on how many things depend on the change, not on how big the component is. The trade: lists still need keyed reconciliation, and there’s more compiler magic between your code and what runs.
Vue’s hybrid
Vue keeps a virtual DOM but its template compiler marks dynamic nodes with patch flags and hoists static subtrees, so the diff skips everything that can’t change. Vue’s experimental Vapor mode drops the virtual DOM for a Solid-style output.
5. Scheduling and batching
Updates aren’t applied the instant you set state. Every framework batches: collect all changes made in the same tick, then render once. My toy and the signals example both use queueMicrotask for this.
Consequences you’ll hit:
- State isn’t updated synchronously. In React,
setCount(count + 1); console.log(count)logs the old value. In Vue youawait nextTick()to see the updated DOM. - React 18 batches everywhere (timeouts, promises, native events). Before 18 it only batched inside React event handlers.
- Functional updates (
setCount(c => c + 1)) exist because threesetCount(count + 1)calls in one tick all read the same stalecountand give +1, not +3.
React goes further with concurrent rendering: rendering is split into small units of work (fibers), and the scheduler yields to the browser every few milliseconds so typing stays responsive. Updates have priorities: a keystroke beats a background list filter (useTransition, useDeferredValue). The render phase (calling components, diffing) can be paused and thrown away. The commit phase (touching the DOM) runs all at once so the user never sees half an update.
6. Effects and lifecycle
Code that has to run because something rendered: fetching data, subscribing to a WebSocket, measuring layout, starting a timer.
| Need | React | Vue | Svelte 5 | Solid |
|---|---|---|---|---|
| After mount | useEffect(fn, []) | onMounted | $effect / onMount | onMount |
| When X changes | useEffect(fn, [x]) | watch(x, fn) | $effect (auto-tracked) | createEffect (auto-tracked) |
| After DOM update, before paint (measure layout) | useLayoutEffect | watch(x, fn, { flush: 'post' }) | $effect (already runs after the DOM update) | createEffect (runs after render) |
| Cleanup | return a function | onUnmounted / onCleanup | return a function | onCleanup |
React’s dependency arrays are manual dependency tracking. Forget one and the effect uses stale values. Signal frameworks track dependencies automatically, which removes that whole class of bug.
Cleanup is not optional. Every subscription, listener, timer and observer you create in an effect must be undone, or it leaks and keeps firing after the component is gone. React’s Strict Mode deliberately mounts, unmounts and re-mounts components in development to flush out missing cleanups.
7. Events
Templates attach handlers (onClick, @click, on:click). Under the hood:
- React doesn’t attach a listener per element. It attaches one listener per event type on the root container (since React 17; earlier on
document) and dispatches to your handlers itself using its own tree. That’s why changingonClickto a new arrow function each render costs nothing, and why React has its own “synthetic event” wrapper. - Solid also delegates common events to the document.
- Vue and Svelte mostly attach real listeners on the elements, with the compiler caching handlers so they aren’t re-bound.
Two-way binding (v-model, bind:value, Angular’s [(ngModel)]) is shorthand for “set the value from state, and update state on input”. React makes you write both halves (value + onChange), which is the “controlled input” pattern.
8. Data flow and shared state
- Props down, events up. A parent passes data to a child. The child calls a callback (or emits an event) to ask the parent to change it. One-way data flow makes it clear who owns each piece of state.
- Prop drilling is passing props through five layers that don’t use them. Context / provide-inject lets a deep child read a value from a distant ancestor directly. In React, every consumer re-renders when the context value changes, so context is good for rarely changing values (theme, current user), not for fast-changing state.
- Stores (Redux, Zustand, Pinia, Svelte stores, a signal in a module) hold state outside the component tree. Subscribers re-render when the part they read changes.
- Server state (fetched data) is different from UI state: it’s a cache with loading, error, staleness and refetching. Libraries like TanStack Query handle that rather than a general store.
9. Server rendering and hydration
A client-only app ships an empty <div id="root"> and builds everything in JavaScript. Users see nothing until the JS downloads and runs, and crawlers may not see the content.
Server-side rendering (SSR) runs the components on the server and sends real HTML. The page is visible immediately. Then the same JS loads in the browser and hydrates: it renders the component tree again, walks the existing DOM instead of creating new nodes, and attaches event listeners and state.
Hydration has costs:
- You render twice: once on the server, once in the browser. The page looks ready before it is (“uncanny valley”: the button is there but clicking does nothing).
- Hydration mismatches. If the client renders different output (
Date.now(),Math.random(),window.innerWidth, locale-dependent formatting, a browser extension that edited the DOM), the framework warns and may throw away the server HTML and re-render. - You ship JS for static content too, because hydration needs the whole tree.
Newer approaches attack those costs:
| Approach | Idea | Used by |
|---|---|---|
| Islands | Page is static HTML. Only interactive widgets ship JS and hydrate, independently | Astro, Fresh |
| Streaming SSR | Send HTML in chunks as data resolves, hydrate parts as they arrive | React 18+ Suspense, SolidStart, Nuxt |
| Server Components | Some components only ever run on the server and ship zero JS. Client components are the interactive leaves | React Server Components (Next.js App Router) |
| Resumability | Serialise the app’s state and listeners into the HTML. Nothing re-runs on load; code for a handler downloads when it’s first used | Qwik |
| Static generation | Render at build time instead of per request | All meta-frameworks |
Meta-frameworks (Next.js, Nuxt, SvelteKit, SolidStart, Remix / React Router, Astro, Angular SSR) add the server side: file-based routing, data loading, SSR, bundling and deployment. The UI framework handles components. The meta-framework handles the app.
10. The build step
Almost every framework needs a compiler now:
- JSX → function calls (Babel, esbuild, SWC).
- Templates and
.vue/.sveltefiles → JavaScript with the static/dynamic analysis above. - React Compiler adds automatic memoisation.
- Bundling and code splitting (Vite, webpack, Turbopack, Rspack): one chunk per route, lazy-load the rest.
- Dev server with hot module replacement: swap a changed component in place and keep its state.
- Tree shaking drops framework features you don’t use.
The same counter in each
// React: component re-runs on every change
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// Solid: function runs once; only the {count()} binding updates
function Counter() {
const [count, setCount] = createSignal(0);
return <button onClick={() => setCount(c => c + 1)}>{count()}</button>;
}<!-- Svelte 5: compiled; $state is a signal under the hood -->
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>{count}</button><!-- Vue 3: ref is a signal; template compiled to an optimised render function -->
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
<template><button @click="count++">{{ count }}</button></template>They look almost the same. The difference is what runs when count changes: React re-runs Counter and diffs a tree. Solid and Svelte run one text update. Vue re-runs the render function and diffs only the parts marked dynamic.
Comparison
| React | Vue 3 | Svelte 5 | Solid | Angular (recent) | |
|---|---|---|---|---|---|
| Syntax | JSX | Templates (or JSX) | Compiled templates | JSX | Templates |
| Reactivity | setState + re-render | Proxies / refs (signals) | Runes (signals) | Signals | Signals + zone.js (zoneless possible) |
| DOM updates | Virtual DOM diff | Compiler-optimised VDOM | Direct, compiled | Direct, compiled | Compiled incremental DOM |
| Component function runs | Every render | Once (setup) | Once | Once | Class instance, once |
| Memoisation | Manual or React Compiler | Mostly automatic | Automatic | Automatic | Automatic for signals |
| Main meta-framework | Next.js, React Router | Nuxt | SvelteKit | SolidStart | Angular SSR |
Pick by ecosystem, hiring and team familiarity more than raw speed. All of them are fast enough for nearly every app. Slow frontends are usually slow because of too much JS, waterfalls of data fetching, or huge unvirtualised lists, not because of the framework’s diff.
A real-world example
A collaborative notes app with a rich-text editor and a sidebar of thousands of notes:
- The editor isn’t rendered by the framework. Rich-text editors (ProseMirror, Lexical, CodeMirror) manage their own DOM. The framework renders a container and hands the editor a DOM node. If the framework re-renders that container and replaces the node, the cursor and the user’s selection are lost, so it’s isolated in its own component that never re-renders.
- The sidebar list is virtualised: only the ~30 visible rows exist in the DOM, keyed by note id. Keys stop the “draft typed in the wrong row” bug when notes are reordered by a remote edit.
- Live updates from the sync server arrive many times a second. They’re written into a store and batched, so ten incoming changes cause one render.
- Expensive work (search indexing, Markdown rendering) runs in a Web Worker so it never blocks the framework’s render.
- Server rendering is only used for public, shared pages. The logged-in app is client-rendered, because its data is in local storage and per user.
- Effects that subscribe (WebSocket, BroadcastChannel, ResizeObserver) all return cleanup functions, verified by React Strict Mode’s double mount in development.
Gotchas
- Missing or index keys in dynamic lists attach DOM state (inputs, focus, child component state) to the wrong item.
- Mutating state directly in React (
items.push(x); setItems(items)) does nothing: same array reference, so React bails out. Create a new array. - Stale closures. An effect or timer captures
countfrom the render it was created in. Use functional updates or refs, or a signal framework. - Effect without cleanup leaks listeners and keeps firing after unmount.
- Effects that set state they depend on loop forever.
- Reading state right after setting it gives the old value. Updates are batched.
- Hydration mismatches from dates, random ids,
windowchecks or locale formatting during render. Generate ids with the framework’s helper (useId) and move browser-only code into effects. - Context for fast-changing values in React re-renders every consumer.
- Letting the framework re-render a third-party widget (editor, map, chart) destroys its internal state. Give it a stable node and talk to it through its API.
- Destructuring props in Solid breaks reactivity (you read the value once). Vue has the same issue with destructuring
reactive()objects.
Practice
- Build the signals implementation above, then make
computedlazy and cached (only recomputes when read after a dependency changed). - Build the toy virtual DOM:
h(),create,patch, keyed children. Reproduce the “draft in the wrong row” bug without keys. - Add event delegation to the toy: one listener at the root that finds the handler on the target’s vnode.
- Write the same todo app in React and in Solid. Add
console.login each component body and compare how often each runs. - Server-render the toy to an HTML string, then write a
hydratethat walks existing DOM nodes instead of creating them. Make it warn on a mismatch. - Profile a list of 10,000 rows in React DevTools, then fix it with keys,
memoand virtualisation, measuring each step.
Sources worth reading
- React docs, “Render and Commit”, “Preserving and Resetting State” (keys) and “You Might Not Need an Effect”: https://react.dev/learn
- Dan Abramov, “React as a UI Runtime” (overreacted.io)
- Andrew Clark, “React Fiber Architecture” (GitHub, acdlite/react-fiber-architecture)
- Ryan Carniato’s articles on fine-grained reactivity (dev.to, Solid’s author)
- Vue docs, “Reactivity in Depth” and “Rendering Mechanism”: https://vuejs.org/guide/extras/reactivity-in-depth
- Rich Harris, “Rethinking Reactivity” (talk) and the Svelte 5 runes announcement
- Preact Signals docs and source (a small, readable signals implementation)
- Miško Hevery’s posts on resumability (Qwik docs)
- The TC39 Signals proposal, an attempt to standardise signals in JavaScript: https://github.com/tc39/proposal-signals