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 diff

The 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 textContent bookkeeping.
  • 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.
FrameworkHow it detects changeWhat you writeCharacter
Reactre-runs your component, diffs a virtual DOMJSX plus hooksthe default hire, biggest ecosystem
Vueproxy-tracked reactive objects, virtual DOMtemplates (or JSX) plus ref/reactivegentle learning curve, batteries included
Sveltecompiler rewrites assignments into DOM updatesmarkup with $state runesleast runtime, closest to plain JS
Solidsignals, no diffing, components run onceJSX plus signalsfastest 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 changeruns your component again, diffs the outputruns only the expressions that read that value
Cost modelproportional to the size of the component treeproportional to the number of dependent expressions
Where the surprises arestale closures, dependency arrays, needless re-rendersreading a signal outside a tracked scope, so nothing updates
What you must remembermemoisation (useMemo, memo) when diffing gets expensivecall the getter, and only inside an effect or template
Debuggingrender counts in devtools, easy to see, hard to reducedependency 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 learnedWhere it shows up in a framework
array methods (map, filter)rendering lists is items.map(...), every time, in every framework
destructuring and spreadprops, state updates, immutable copies of objects and arrays
closureshooks and signals are closures. Stale-closure bugs are closure bugs
this and arrow functionswhy handlers are arrows, and why class components needed bind
pure functionsa component is a pure function of props. Reducers must be pure
promises and async/awaitdata fetching, loading and error states, cancellation on unmount
events and delegationsynthetic events, onClick, why event.preventDefault() still matters
modules and toolingthe build, the dev server, code splitting with dynamic import()
localStorage, fetch, the DOMstill 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 called a.get(), so it never subscribed. Setting a notifies 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, ref instead of useState, different lifecycle names: roughly a week of adjustment. Component decomposition, immutable updates, list rendering with map, data fetching with async/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.