# Building Your Own Mini Framework

> React, Vue, and Svelte feel like magic until you build the two ideas underneath them yourself: a virtual DOM diff and a signal-based reactivity system. Write both from scratch in plain JavaScript.


---

# Building Your Own Mini Framework

Every framework tutorial tells you to trust the black box: change some state, and the UI updates. That's the entire pitch, and it works, right up until something re-renders when it shouldn't, or doesn't when it should, and you have no mental model to debug from. The fix isn't reading React's source (millions of lines, years of edge cases). It's building the two ideas underneath it yourself, small enough to fit in your head.

This guide assumes you're comfortable with the real DOM - `querySelector`, `addEventListener`, creating and removing nodes by hand. If any of that is shaky, work through [The DOM Explained](/guides/the-dom-explained) first. Here, the DOM is a tool you already have; the question is what to build on top of it.

## The phases

1. **[A Virtual DOM From Scratch](01-a-virtual-dom-from-scratch.md)** - why frameworks avoid touching the real DOM for every change, and a working `diff()` / `patch()` pair, under 60 lines, that you can read top to bottom.
2. **[Reactivity Without Magic](02-reactivity-without-magic.md)** - a `createSignal()` built from a value and a subscriber list, wired into phase 1's renderer so changing state triggers a real re-render.
3. **[What React/Vue/Svelte Actually Add On Top](03-what-react-vue-svelte-actually-add-on-top.md)** - the real gap between your ~100 lines and a production framework: lifecycle hooks, batching, keyed diffing, fiber scheduling, and Svelte's compiler.

By the end, "the framework re-rendered the component" stops being a black box and becomes a sequence of function calls you wrote yourself. That's also the natural jumping-off point into [what a framework even is](/guides/what-a-framework-even-is) at a higher level.


---

# A Virtual DOM From Scratch

Here's the naive way to render a counter that ticks every second:

```js
function render(count) {
  document.getElementById("app").innerHTML = `<div>Count: ${count}</div>`;
}
setInterval(() => render(count++), 1000);
```

That works, and it's wrong for reasons you don't feel until the page gets bigger. `innerHTML` on a whole container tears down every node inside it and rebuilds them - event listeners you attached get silently dropped, an `<input>` the user was typing into loses focus and its cursor position, and a browser has to re-run layout and paint for the entire subtree instead of one text node. Real DOM writes go through style recalculation and layout - operations a browser tries hard to batch and skip, but only if you give it small, targeted changes instead of "throw everything away and start over."

Frameworks solve this by never handing the DOM a wholesale replacement. Instead they keep a lightweight description of what the UI *should* look like, compare it to the previous description, and touch only the real nodes that differ. That lightweight description is the "virtual DOM" - and it's less exotic than the name suggests.

## The tree as plain objects

A virtual DOM node is a plain JavaScript object with a tag, some props, and children:

```js
function h(tag, props, ...children) {
  return { tag, props: props || {}, children };
}
```

`h` (short for "hyperscript," the name React and Vue both use internally) just builds a tree:

```js
const view = h("div", { class: "card" },
  h("h2", null, "Count: 3"),
  h("button", null, "+1")
);
```

That's it - no DOM involved yet. It's data: an object you can compare, log, and diff without touching the browser at all.

## Rendering a tree for the first time

Before diffing two trees, you need a way to turn one tree into real nodes:

```js
function createEl(vnode) {
  if (typeof vnode === "string") return document.createTextNode(vnode);

  const el = document.createElement(vnode.tag);
  for (const [key, value] of Object.entries(vnode.props)) {
    el.setAttribute(key, value);
  }
  vnode.children.forEach(child => el.appendChild(createEl(child)));
  return el;
}
```

This runs once, on first render. From then on, you never call it again for the whole tree - only `diff` and `patch` touch the DOM.

## Comparing two trees

`diff()` takes an old vnode and a new vnode and returns a list of changes - it does not touch the DOM itself:

```js
function diff(oldNode, newNode) {
  if (oldNode === undefined) return { type: "CREATE", newNode };
  if (newNode === undefined) return { type: "REMOVE" };
  if (changed(oldNode, newNode)) return { type: "REPLACE", newNode };
  if (typeof newNode === "string") return { type: "NONE" };

  const childPatches = [];
  const len = Math.max(oldNode.children.length, newNode.children.length);
  for (let i = 0; i < len; i++) {
    childPatches.push(diff(oldNode.children[i], newNode.children[i]));
  }
  return { type: "UPDATE", props: diffProps(oldNode.props, newNode.props), childPatches };
}

function changed(a, b) {
  if (typeof a !== typeof b) return true;
  if (typeof a === "string") return a !== b;
  return a.tag !== b.tag;
}

function diffProps(oldProps, newProps) {
  const patches = [];
  for (const key in newProps) {
    if (oldProps[key] !== newProps[key]) patches.push({ key, value: newProps[key] });
  }
  for (const key in oldProps) {
    if (!(key in newProps)) patches.push({ key, value: null });
  }
  return patches;
}
```

Four outcomes, and that covers the whole tree: a node is new (`CREATE`), gone (`REMOVE`), swapped for something of a different type (`REPLACE`), or the same element with possibly different props and children (`UPDATE`, recursing into each child by position).

## Applying the changes

`patch()` walks the real DOM alongside the patch tree and makes the minimum edits:

```js
function patch(parent, patches, index = 0) {
  if (!patches) return;
  const el = parent.childNodes[index];

  switch (patches.type) {
    case "CREATE":
      parent.appendChild(createEl(patches.newNode));
      break;
    case "REMOVE":
      parent.removeChild(el);
      break;
    case "REPLACE":
      parent.replaceChild(createEl(patches.newNode), el);
      break;
    case "UPDATE":
      patches.props.forEach(({ key, value }) => {
        if (value === null) el.removeAttribute(key);
        else el.setAttribute(key, value);
      });
      patches.childPatches.forEach((childPatch, i) => patch(el, childPatch, i));
      break;
  }
}
```

Wire it together and the counter from the top of this phase becomes:

```js
let oldTree = h("div", null, h("span", null, "Count: 0"));
const root = document.getElementById("app");
root.appendChild(createEl(oldTree));

function update(count) {
  const newTree = h("div", null, h("span", null, `Count: ${count}`));
  patch(root, diff(oldTree, newTree));
  oldTree = newTree;
}
```

Only the text node inside `<span>` changes on the real DOM. The `<div>` and `<span>` elements themselves are never touched, so their attributes, any listeners attached directly to them, and focus state all survive. That's the entire trick: describe the UI as data, compare two descriptions, apply only the difference.

Check your understanding of why this beats direct DOM writes.

```quiz
[
  {
    "q": "Why does replacing a container's innerHTML on every update cause problems?",
    "choices": [
      "It's slower to type than querySelector",
      "It destroys and recreates every node inside, dropping listeners and focus state",
      "innerHTML doesn't work with CSS classes"
    ],
    "answer": 1,
    "explain": "Every element inside is torn down and rebuilt, even ones that didn't change."
  },
  {
    "q": "In the diff() function, what does an UPDATE patch mean?",
    "choices": [
      "The node type changed and needs full replacement",
      "The node was removed",
      "Same tag, but props and/or children may differ - recurse into children"
    ],
    "answer": 2
  },
  {
    "q": "Where does createEl() get called after the initial render?",
    "choices": [
      "Every time update() runs",
      "Only for CREATE and REPLACE patches, to build the specific new node needed",
      "Never again"
    ],
    "answer": 1,
    "explain": "UPDATE patches reuse the existing element and only touch its attributes/children."
  }
]
```


---

# Reactivity Without Magic

Phase 1 built `diff()` and `patch()`: given an old tree and a new tree, update only what changed. That still leaves a gap. Something has to notice that state changed, build the new tree, and call `patch()` at the right moment. In React that's `setState`. In Vue and Svelte it's a signal or a reactive variable. All of them reduce to the same small idea: a value that remembers who's watching it.

## The signal

A signal is a box holding a value and a list of functions to call when that value changes:

```js
function createSignal(initialValue) {
  let value = initialValue;
  const subscribers = new Set();

  function read() {
    return value;
  }

  function write(newValue) {
    value = newValue;
    subscribers.forEach(fn => fn(value));
  }

  function subscribe(fn) {
    subscribers.add(fn);
    return () => subscribers.delete(fn);
  }

  return [read, write, subscribe];
}
```

`read` and `write` are what your code calls directly. `subscribe` is what the *renderer* calls, once, to register itself as a listener. Nobody polls for changes and nobody watches the whole app - `write` fires exactly the subscribers registered on that one signal.

```js
const [count, setCount] = createSignal(0);

count(); // 0
setCount(5);
count(); // 5
```

Notice `subscribe` isn't even used yet here - this part alone is just a value with a notification list, no DOM involved. That separation matters: you can test a signal in a plain script, no browser needed.

## Wiring it to the renderer

Connect a signal to phase 1's `diff`/`patch` by subscribing a render function that rebuilds the tree and patches the real DOM:

```js
function mount(root, renderFn, ...signals) {
  let oldTree = renderFn();
  root.appendChild(createEl(oldTree));

  function rerender() {
    const newTree = renderFn();
    patch(root, diff(oldTree, newTree));
    oldTree = newTree;
  }

  signals.forEach(([, , subscribe]) => subscribe(rerender));
}
```

`renderFn` is a function that reads signals and returns a vnode tree - the same `h()` calls from phase 1, just wrapped so they can run more than once:

```js
const [count, setCount] = createSignal(0);

function view() {
  return h("div", null,
    h("span", null, `Count: ${count()}`),
    h("button", { onclick: "increment()" }, "+1")
  );
}

mount(document.getElementById("app"), view, [count, setCount, count.subscribe]);

function increment() {
  setCount(count() + 1);
}
```

(Real frameworks attach `onclick` as an actual event listener rather than a string, and track which signals a component *actually read* automatically - more on that gap in phase 3. This version keeps the wiring explicit so you can see it.)

Call `increment()` and the sequence is: `setCount` runs, updates the stored value, calls every subscriber - here just `rerender` - which calls `view()` again to get a new tree, diffs it against the old one, and patches only the `<span>`'s text. The button, its listener, any other untouched DOM: left alone.

## Why this is the whole loop

This is the mechanism people mean when they say a framework is "reactive": state holds a list of interested parties, and changing the state notifies exactly those parties, who then reconcile the UI through a diff. No magic, no polling, no framework runtime scanning your app for changes every frame. It's a subscriber list and a function call.

The gap between this and a real signal library (like Solid's or Vue's) is mostly about *how subscriptions get registered*. Here, you list `[count, setCount, count.subscribe]` by hand. Production reactivity systems track dependencies automatically: when `view()` runs, the framework notices it called `count()` and subscribes for you, with no explicit list. That's a real engineering feat, but it's an optimization on top of this loop, not a different loop.

Trace through the update sequence once more before moving on.

```quiz
[
  {
    "q": "What does createSignal's write() function do?",
    "choices": [
      "Saves the value to localStorage",
      "Updates the stored value and calls every subscribed function",
      "Immediately re-renders the whole page"
    ],
    "answer": 1
  },
  {
    "q": "In mount(), what triggers patch() to run again after the first render?",
    "choices": [
      "A setInterval timer checking for changes",
      "A signal's write() calling the subscribed rerender function",
      "The browser automatically detects DOM differences"
    ],
    "answer": 1,
    "explain": "rerender is registered via subscribe(), so it only runs when write() calls it."
  },
  {
    "q": "What do production frameworks automate that this mini version does by hand?",
    "choices": [
      "The diff() algorithm itself",
      "Detecting which signals a render function read, and subscribing automatically",
      "Creating DOM elements"
    ],
    "answer": 1
  }
]
```


---

# What React/Vue/Svelte Actually Add On Top

Phases 1 and 2 built a real diff/patch renderer and a real signal system, wired together, in under 150 lines total. That's not a toy that resembles a framework - it's the actual core loop every virtual-DOM framework runs. It's worth being precise about what's still missing, because the gap is where framework teams spend most of their engineering effort.

## Lifecycle hooks

Your `mount()` function renders once and reacts to signal writes forever. Real components need to run code at specific moments: after first mount (fetch data), before an update (compare props), after unmount (cancel a subscription, clear a timer). React's `useEffect`, Vue's `onMounted`/`onUnmounted`, Svelte's `onMount` all exist because "just re-run the render function" isn't enough - side effects need their own hooks into the same lifecycle, with cleanup guaranteed even if the component disappears mid-flight.

## Batching updates

In phase 2, `setCount(1)` followed immediately by `setCount(2)` triggers `rerender()` twice - once per `write()` call. In a real app, an event handler that updates three different signals would cause three separate diff/patch passes in your version. Frameworks batch: multiple state changes within the same tick (a single event handler, for instance) collapse into one re-render. React does this by queuing updates and flushing them together; Vue's reactivity system defers effects to a microtask. The visible symptom when batching is missing: janky, redundant DOM work for something that should be a single atomic change.

## Keyed diffing

The `diff()` from phase 1 compares children by index - position 0 against position 0, position 1 against position 1. That breaks down on reordering. Insert one item at the front of a 100-item list and every single item shifts position, so the diff sees 100 "changed" nodes instead of one "inserted" node. Production differs assign each child a stable `key` and match by key first, position second - the same list, reordered, produces a handful of moves instead of a full rewrite. This is why React warns loudly when you render a list without `key` props: without it, the diff degrades to the naive index-based version you just built.

## Fiber-style scheduling

Your `patch()` walks the whole patch tree synchronously, in one function call, blocking the main thread until it finishes. For a small tree that's invisible. For a large one, a big update can block user input for long enough to feel like a freeze. React's fiber architecture breaks rendering work into interruptible units, so it can pause mid-render, let the browser handle a keystroke or a paint, and resume - prioritizing urgent work (typing) over less urgent work (a list re-render off-screen). That's a scheduler bolted onto the diff algorithm, not a different diff algorithm.

## A compiler step: Svelte's different bet

React and Vue ship the diffing algorithm to the browser and run it at runtime, every render. Svelte takes a different approach: it's a compiler. At build time, Svelte reads your component and generates JavaScript that updates the exact DOM node a variable affects - no virtual DOM tree, no diff, no patch. If `count` only ever appears in one `<span>`, the compiled output is close to `span.textContent = count` directly, decided at build time instead of computed at runtime. This is why Svelte bundles can be smaller and updates faster for simple cases: there's no diffing library, and no tree comparison at all - the "diff" already happened, once, in your terminal, before the browser ever ran a line.

## Developer tooling

The last gap is invisible until you need it: React DevTools and Vue DevTools let you inspect the component tree, see which signal changed and which component re-rendered because of it, and time-travel through state changes. None of that comes from the diff or the signal system - it's a parallel layer of instrumentation, hooks into every render and every write, that frameworks maintain because debugging "why did this re-render" without it is close to impossible at scale.

None of this means phases 1 and 2 were a simplification to the point of being wrong. The loop you built - state changes, subscribers fire, a new tree gets diffed against the old one, only the difference touches the DOM - is the actual mechanism. What's layered on top is what turns a working idea into something that survives a 500-component app with real users typing into it while data streams in from a server. Knowing where that line sits is the difference between fearing the framework and reading its source.

One more pass before you're done with this guide.

```quiz
[
  {
    "q": "Why do frameworks warn about missing 'key' props on list items?",
    "choices": [
      "Keys are required for CSS styling",
      "Without keys, diffing falls back to comparing children by index, turning a single insert into a full rewrite",
      "Keys prevent memory leaks"
    ],
    "answer": 1
  },
  {
    "q": "What problem does update batching solve?",
    "choices": [
      "It makes signals store more values",
      "It collapses multiple state changes in the same tick into a single re-render instead of one per change",
      "It removes the need for a diff algorithm"
    ],
    "answer": 1
  },
  {
    "q": "How does Svelte's approach differ from React's and Vue's at a fundamental level?",
    "choices": [
      "Svelte compiles away the diff step, generating direct DOM updates at build time instead of comparing trees at runtime",
      "Svelte doesn't support component state",
      "Svelte uses a faster virtual DOM diffing algorithm than React"
    ],
    "answer": 0,
    "explain": "React and Vue diff at runtime; Svelte's compiler figures out the exact update ahead of time."
  }
]
```

Next stop: zoom back out to [what a framework even is](/guides/what-a-framework-even-is) and see how this core loop fits into the bigger picture of routing, state management, and ecosystem tooling.
