Frameworks Explained
Mental model: Every framework solves one problem: keeping the screen equal to the state without you writing the update code. They differ only in how they find out what changed.
Level: beginner · about 18 minutes
You already know how to build a UI: query an element, set its text, attach a listener. It works fine until there are twelve pieces of state and forty places on screen that depend on them. Then you spend your days answering one question: "when this value changes, which parts of the page do I have to remember to update?" Every framework exists to delete that question.
Imperative: you update the screen
let count = 0;
btn.onclick = () => {
count++;
label.textContent = count;
badge.textContent = count;
total.textContent = count * price;
clearBtn.disabled = count === 0;
};
// forget one line and the UI lies
Declarative: you describe the screen
let count = 0;
function view(state) {
return {
label: state.count,
badge: state.count,
total: state.count * price,
clearDisabled: state.count === 0,
};
}
// the framework applies the diffThe right-hand version is a pure function from state to a description of the screen. That is the whole idea behind React, Vue, Svelte and Solid. Everything else in those frameworks is machinery for running that function at the right moment and applying the result efficiently.
What they actually give you
- Automatic updates. You change state, the right DOM changes. No manual
textContentbookkeeping. - Components. A named unit of markup, state and behaviour you can reuse and test in isolation, instead of one 900-line file.
- A lifecycle. Defined places to run setup and, more importantly, cleanup (timers, listeners, subscriptions), so leaks are harder.
- Conventions. Routing, forms and data fetching get one obvious shape, so a new teammate can read your code on day one.
- An ecosystem. Component libraries, devtools, testing utilities and 200 answered questions for every problem you will hit.
| Framework | How it detects change | What you write | Character |
|---|---|---|---|
| React | re-runs your component, diffs a virtual DOM | JSX plus hooks | the default hire, biggest ecosystem |
| Vue | proxy-tracked reactive objects, virtual DOM | templates (or JSX) plus ref/reactive | gentle learning curve, batteries included |
| Svelte | compiler rewrites assignments into DOM updates | markup with $state runes | least runtime, closest to plain JS |
| Solid | signals, no diffing, components run once | JSX plus signals | fastest updates, sharpest mental model |
Virtual DOM versus signals, honestly
A virtual DOM re-runs your component function, builds a cheap object tree describing the new output, compares it with the previous tree, and applies the differences. Signals invert that: each piece of state keeps a list of the exact expressions that read it, so a change updates only those. One re-runs and compares, the other subscribes and pinpoints.
| Virtual DOM (React, Vue) | Signals (Solid, Svelte 5, Vue refs) | |
|---|---|---|
| On a state change | runs your component again, diffs the output | runs only the expressions that read that value |
| Cost model | proportional to the size of the component tree | proportional to the number of dependent expressions |
| Where the surprises are | stale closures, dependency arrays, needless re-renders | reading a signal outside a tracked scope, so nothing updates |
| What you must remember | memoisation (useMemo, memo) when diffing gets expensive | call the getter, and only inside an effect or template |
| Debugging | render counts in devtools, easy to see, hard to reduce | dependency graphs, harder to see, usually already minimal |
Neither is magic and neither is a scandal. Diffing does more work per change but keeps your component a plain function you can reason about top to bottom. Signals do less work but ask you to respect where tracking happens. For most applications the user cannot tell the difference, so pick for the ecosystem and the team, not the benchmark.
let running = null; // the effect currently being run
function signal(value) {
const subscribers = new Set();
return {
get() {
if (running) subscribers.add(running); // whoever is reading depends on me
return value;
},
set(next) {
if (next === value) return; // no change, no work
value = next;
for (const run of [...subscribers]) run();
},
};
}
function effect(fn) {
const run = () => {
const previous = running;
running = run; // start tracking
try { fn(); } finally { running = previous; }
};
run(); // run once to collect dependencies
}
// --- use it like a framework -------------------------------------------
const screen = [];
const name = signal('Ada');
const count = signal(0);
effect(() => screen.push(`${name.get()} clicked ${count.get()} times`));
count.set(1);
name.set('Grace');
count.set(1); // same value, so no re-render
console.log(screen);
// -> [ 'Ada clicked 0 times', 'Ada clicked 1 times', 'Grace clicked 1 times' ]A reactive renderer in about thirty lines. This is the core of Solid, Vue refs and Svelte runes.
Thirty lines, no build step, no dependencies, and the essential behaviour of a modern framework: read a value and you are subscribed, write a value and only the dependents re-run. Replace screen.push with a DOM write and you have a rendering library. What real frameworks add is components, batching, cleanup, keyed lists, error boundaries, server rendering and ten years of edge cases. Useful, but not mysterious.
let running = null;
function signal(v) {
const subs = new Set();
return {
get() { if (running) subs.add(running); return v; },
set(n) { v = n; for (const f of [...subs]) f(); },
};
}
function effect(fn) { const run = () => { running = run; fn(); running = null; }; run(); }
const a = signal(1);
const b = signal(10);
let runs = 0;
effect(() => { runs += 1; a.get(); }); // reads a only
b.set(20);
a.set(2);
console.log(runs);The effect runs once immediately to discover its dependencies, and it only read a. So b.set(20) notifies nobody, and a.set(2) re-runs it: two runs in total. That is fine-grained reactivity in one sentence: a write only reaches the expressions that actually read that value.
Which of your skills transfer directly
| What you learned | Where it shows up in a framework |
|---|---|
array methods (map, filter) | rendering lists is items.map(...), every time, in every framework |
| destructuring and spread | props, state updates, immutable copies of objects and arrays |
| closures | hooks and signals are closures. Stale-closure bugs are closure bugs |
this and arrow functions | why handlers are arrows, and why class components needed bind |
| pure functions | a component is a pure function of props. Reducers must be pure |
promises and async/await | data fetching, loading and error states, cancellation on unmount |
| events and delegation | synthetic events, onClick, why event.preventDefault() still matters |
| modules and tooling | the build, the dev server, code splitting with dynamic import() |
localStorage, fetch, the DOM | still exactly the same APIs, called from a lifecycle hook |
A component, with no framework at all
const Badge = ({ count }) => ({ tag: 'span', className: 'badge', text: String(count) });
const CartRow = ({ name, qty }) => ({
tag: 'li',
children: [
{ tag: 'span', text: name },
Badge({ count: qty }),
],
});
const Cart = ({ items }) => ({ tag: 'ul', children: items.map(CartRow) });
function toHtml(node) {
const cls = node.className ? ` class="${node.className}"` : '';
const inner = node.children ? node.children.map(toHtml).join('') : (node.text ?? '');
return `<${node.tag}${cls}>${inner}</${node.tag}>`;
}
console.log(toHtml(Cart({ items: [{ name: 'Tea', qty: 2 }, { name: 'Coffee', qty: 1 }] })));
// -> <ul><li><span>Tea</span><span class="badge">2</span></li><li><span>Coffee</span><span class="badge">1</span></li></ul>A component is a function that takes props and returns a description of output. That is it.
state ──► your component function ──► description of the UI ^ | | v | diff or dependency graph | | +──── event handler ◄──── real DOM ◄──── minimal updates
Try it yourself
Add a computed value
let running = null;
function signal(value) {
const subs = new Set();
return {
get() { if (running) subs.add(running); return value; },
set(next) { if (next === value) return; value = next; [...subs].forEach((f) => f()); },
};
}
function effect(fn) {
const run = () => { const prev = running; running = run; try { fn(); } finally { running = prev; } };
run();
}
const price = signal(10);
const qty = signal(2);
effect(() => console.log('total:', price.get() * qty.get()));
// -> total: 20
qty.set(3); // -> total: 30
price.set(1); // -> total: 3
Add computed(fn) that caches its value and only recalculates when a dependency changes. Then check your work: an effect reading the computed should run once per real change, not once per read.
Why lists need keys
const before = [{ id: 'a', text: 'Tea' }, { id: 'b', text: 'Coffee' }, { id: 'c', text: 'Cocoa' }];
const after = [{ id: 'b', text: 'Coffee' }, { id: 'c', text: 'Cocoa' }];
function diffByIndex(prev, next) {
const ops = [];
const max = Math.max(prev.length, next.length);
for (let i = 0; i < max; i++) {
if (!next[i]) ops.push(`remove row ${i}`);
else if (!prev[i]) ops.push(`insert row ${i}`);
else if (prev[i].text !== next[i].text) ops.push(`rewrite row ${i}: ${prev[i].text} -> ${next[i].text}`);
}
return ops;
}
function diffByKey(prev, next) {
const nextIds = new Set(next.map((n) => n.id));
return prev.filter((p) => !nextIds.has(p.id)).map((p) => `remove ${p.id}`);
}
console.log(diffByIndex(before, after)); // 3 operations: every row shifts and gets rewritten
console.log(diffByKey(before, after)); // 1 operation: remove a
Delete the first item and compare the two diffs. Then change diffByIndex to reuse rows by id and count how many DOM operations each strategy would need.
Exercises
Build the reactive core
Write createSignal(initial) returning [get, set], and createEffect(fn) which runs fn once immediately and again whenever a signal it read is set to a different value. Reading a signal inside an effect subscribes that effect. Setting a signal to its current value must not re-run anything.
Check yourself
- What is the one problem every UI framework is built to solve?
- Keeping the rendered output equal to the state without hand-written update code — Everything else (components, routing, JSX, hooks) is machinery around that one job. Frameworks are usually slower than perfectly hand-written imperative updates. You accept a small cost so that no piece of your UI can quietly go out of sync with the data behind it.
- How does a signal know which parts of the UI to update?
- It records which effects read it while they were running, and notifies only those — Reading a signal inside a tracked scope registers that scope as a subscriber, so a write goes straight to the dependents. Diffing trees is the virtual DOM strategy. Svelte does use compile-time analysis, but the runtime relationship is still a subscription.
- What does this print?
1— The effect runs once immediately, but it never calleda.get(), so it never subscribed. Settinganotifies an empty set. This is the classic signals bug in reverse: if you read a value outside a tracked scope (or forget to call the getter), the update silently never happens.- You know React and switch to a team using Vue. What actually has to be relearned?
- The syntax and the reactivity rules, while state modelling, async, arrays and closures carry over — Templates instead of JSX,
refinstead ofuseState, different lifecycle names: roughly a week of adjustment. Component decomposition, immutable updates, list rendering withmap, data fetching withasync/await, and closure-driven bugs are the same in both, and they are the majority of the work.
Common mistakes
- Learning a framework before array methods, destructuring and closures. The framework then looks like magic and every bug is unfixable.
- Believing a virtual DOM is faster than direct DOM updates. It is a convenience with a cost, not a speed-up.
- Reading a signal outside a tracked scope and concluding reactivity is broken. Nothing subscribed, so nothing re-ran.
- Rendering lists without stable keys, then wondering why input focus and row state jump around after a delete.
- Choosing a framework from benchmarks. Ecosystem, hiring and team familiarity decide real projects.
- Forgetting cleanup in a lifecycle hook. Timers, listeners and subscriptions outlive the component that made them.
Takeaways
- Frameworks exist to keep the screen equal to the state so you never hand-write update code.
- A virtual DOM re-runs and compares; signals subscribe and pinpoint. Both are ordinary code, not magic.
- A component is a function from props to a description of output. Reactivity is a value plus a subscriber list.
- Fine-grained reactivity in one sentence: reading subscribes, writing notifies only the readers.
- Arrays, closures, destructuring, promises and pure functions transfer completely. Syntax does not.
- Pick for the ecosystem and the jobs near you, then build three real things. Your second framework takes a week.