DevLift
Back to Blog

Build Your Own Virtual DOM

A virtual DOM is a data structure plus four functions, about 150 lines. Build h, createElement, diff and patch — then measure where "minimal DOM operations" is true and where it quietly isn't.

Admin
August 2, 202612 min read0 views

Build Your Own Virtual DOM

"Reconciliation" is the word React uses for the step between "your component returned a new tree" and "the browser's DOM changed". It sounds like a subsystem. It's a data structure plus four functions, and it fits in about 150 lines. Write those and you get an exact understanding of why key props exist, why changing an element's type throws away its subtree, and — more useful — where the "minimum number of DOM operations" story is true and where it quietly isn't.

What We're Building

Four functions with a clean separation of concerns:

  • h(tag, props, ...children) — creates a lightweight JavaScript object (a "vnode") describing an element
  • createElement(vnode) — turns a vnode into a real DOM node
  • diff(oldVnode, newVnode) — compares two vnodes and returns a patch descriptor
  • patch(domNode, patchDesc) — applies the patch to the real DOM

Only patch() touches document. Everything else is pure object manipulation you can test in Node.js without a browser.

Rendering diagram...

Step 1: The vnode — What a Virtual Node Actually Is

A vnode is a plain JavaScript object. That's genuinely all it is.

1

Define the vnode structure and h()

// vdom.js
 
/**
 * Hyperscript — creates a virtual node.
 * h('div', { class: 'box' }, 'hello')
 * h('button', { onclick: handleClick }, 'Click me')
 */
function h(tag, props, ...children) {
  return {
    tag,
    props: props ?? {},
    children: children
      .flat(Infinity)
      // Drop what conditional rendering actually produces:
      //   cond && h(...)       -> false
      //   items?.map(...)      -> undefined
      //   cond ? h(...) : null -> null
      .filter((c) => c !== null && c !== undefined && typeof c !== 'boolean')
      .map((child) =>
        typeof child === 'string' || typeof child === 'number'
          ? { tag: null, text: String(child), props: {}, children: [] }
          : child
      ),
  };
}

That .filter is not defensive padding. h('div', {}, isLoggedIn && h('span', {}, name)) is how anyone actually writes a conditional child, and when isLoggedIn is false the argument that arrives is the boolean false. Without the filter it survives into children and createElement reads .tag off it later — Cannot read properties of null for a null child, Cannot convert undefined or null to object for false. React drops booleans, null and undefined from children for exactly this reason.

There are two kinds of vnode:

  • Text node: tag: null, carries a text string
  • Element node: tag: 'div' (or any tag), has props and children

Strings and numbers in the children array become text vnodes immediately. This keeps the rest of the code uniform — everything in the tree is a vnode, no raw primitives to special-case later.

So h('div', { class: 'box' }, h('span', {}, 'hello')) produces:

// The vnode that h('div', { class: 'box' }, h('span', {}, 'hello')) produces.
const vnode = {
  tag: 'div',
  props: { class: 'box' },
  children: [
    {
      tag: 'span',
      props: {},
      children: [
        { tag: null, text: 'hello', props: {}, children: [] }
      ]
    }
  ]
};

Step 2: createElement — Mount a Vnode to the Real DOM

createElement takes a vnode and returns a real HTMLElement or Text node. This is the initial mount, not updates.

2

Implement createElement to turn vnodes into DOM nodes

// `key` is diff metadata, not an attribute — it must never reach the DOM.
const isAttr = (key) => key !== 'key';
// null / undefined / false all mean "no attribute", the convention JSX uses.
const isAbsent = (v) => v === null || v === undefined || v === false;
 
function setProp(el, key, value) {
  if (!isAttr(key)) return;
 
  if (key.startsWith('on') && typeof value === 'function') {
    el.addEventListener(key.slice(2).toLowerCase(), value);
  } else if (isAbsent(value)) {
    el.removeAttribute(key);
  } else {
    el.setAttribute(key, value === true ? '' : String(value));
  }
}
 
function createElement(vnode) {
  // Text node
  if (vnode.tag === null) {
    return document.createTextNode(vnode.text);
  }
 
  const el = document.createElement(vnode.tag);
 
  // Set props — event listeners vs. attributes
  for (const [key, value] of Object.entries(vnode.props)) {
    setProp(el, key, value);
  }
 
  // Recursively mount children
  for (const child of vnode.children) {
    el.appendChild(createElement(child));
  }
 
  return el;
}

The convention for event handlers: any prop starting with on is treated as a listener. onclickel.addEventListener('click', fn). Everything else is an attribute.

Two rules hide in setProp and both come from the gap between how JavaScript values behave and how HTML attributes behave.

setAttribute stringifies its second argument, so a naive version turns { disabled: false } into disabled="false" — and a disabled attribute disables the input no matter what string it holds. Boolean props have to become presence or absence, not "true" and "false". Same trap for null and undefined, which otherwise render literally as title="null".

key is filtered out because it is information for the diff, not for the browser. Leave it in and every list item ships a nonstandard key="a" attribute to production. We don't use key in the diff yet — see "What We Skipped" — but the moment a reader adds it to a list, it should not leak.

Step 3: diff() — What Changed Between Two Trees?

The diff function compares an old vnode to a new vnode and returns a patch descriptor — a plain object describing what needs to change. Four cases cover everything:

Rendering diagram...
3

Implement the diff algorithm

const PATCH = {
  REPLACE: 'REPLACE', // tear down old, render new
  UPDATE:  'UPDATE',  // same tag, update props + recurse children
  TEXT:    'TEXT',    // text content changed
  SKIP:    'SKIP',    // nothing changed
  REMOVE:  'REMOVE',  // node disappeared
};
 
function diff(oldVnode, newVnode) {
  // Case 1: new node is absent — remove
  if (!newVnode) {
    return { type: PATCH.REMOVE };
  }
 
  // Case 2: completely different types — replace the subtree
  // This fires for text→element, element→text, or different tags
  if (oldVnode.tag !== newVnode.tag) {
    return { type: PATCH.REPLACE, newVnode };
  }
 
  // Case 3: both are text nodes
  if (oldVnode.tag === null) {
    return oldVnode.text !== newVnode.text
      ? { type: PATCH.TEXT, text: newVnode.text }
      : { type: PATCH.SKIP };
  }
 
  // Case 4: same element tag — diff props and children
  const propPatches  = diffProps(oldVnode.props, newVnode.props);
  const childPatches = diffChildren(oldVnode.children, newVnode.children);
 
  if (!propPatches && !childPatches) {
    return { type: PATCH.SKIP };
  }
 
  return { type: PATCH.UPDATE, propPatches, childPatches };
}

The REPLACE shortcut — different tags means full replacement — is the same heuristic React documents under Reconciliation. A <div> becoming a <section> tears down and rebuilds the entire subtree, including any state hanging off it. That is a deliberate assumption, not an optimum: React's docs note that a general tree-diff is O(n³) in the number of elements and that they trade exactness for a linear pass.

Step 4: diffProps and diffChildren

4

Diff props and recurse into children

function diffProps(oldProps, newProps) {
  const patches = [];
 
  // Props that were added or changed
  for (const [key, newVal] of Object.entries(newProps)) {
    if (isAttr(key) && oldProps[key] !== newVal) {
      // oldValue rides along so patch() can detach a replaced listener
      patches.push({ key, value: newVal, oldValue: oldProps[key] });
    }
  }
 
  // Props that were removed.
  // Object.hasOwn, not `key in newProps` — see below.
  for (const key of Object.keys(oldProps)) {
    if (isAttr(key) && !Object.hasOwn(newProps, key)) {
      patches.push({ key, value: null, oldValue: oldProps[key] }); // null = remove
    }
  }
 
  return patches.length > 0 ? patches : null;
}
 
function diffChildren(oldChildren, newChildren) {
  const patches = [];
  const len = Math.max(oldChildren.length, newChildren.length);
  let anyChange = false;
 
  for (let i = 0; i < len; i++) {
    const oldChild = oldChildren[i];
    const newChild = newChildren[i];
 
    if (!oldChild) {
      // Child was added
      patches.push({ type: 'ADD', vnode: newChild });
      anyChange = true;
    } else {
      const childPatch = diff(oldChild, newChild);
      patches.push(childPatch);
      if (childPatch.type !== PATCH.SKIP) anyChange = true;
    }
  }
 
  return anyChange ? patches : null;
}
⚠️

!(key in newProps) is the version you write first, and it is wrong. in walks the prototype chain, and props is an object literal, so 'toString' in {} is true. Any prop whose name collides with something on Object.prototypetoString, valueOf, constructor, hasOwnProperty, isPrototypeOf — looks like it's still present in the new props even when it isn't, so the removal patch is never emitted and the attribute stays on the element forever.

Fuzzing diffProps with random prop names drawn from a pool that mixes ordinary names with Object.prototype members, then comparing the resulting outerHTML against the Object.hasOwn version: 373 of 600 rounds diverged. That rate is a function of how often the generator picks a colliding name, so it isn't a number to quote out of context — the point is only that it isn't zero and it isn't rare once such a name is in play. And the collision set is not exotic in a template language: class, title and id are safe, but a component that renders <td> cells from arbitrary object keys, or an editor that lets a user name a field, gets there quickly.

Object.hasOwn(newProps, key) asks the question the code means to ask. (Note that the first loop is already safe: Object.entries only visits own enumerable properties.)

diffChildren zips the two child arrays by index. When the new array is longer, extras become ADD. When the old array is longer, diff(oldChild, undefined) returns REMOVE (from Case 1 above).

This is positional diffing — no identity tracking. It's the footgun that makes key props necessary (more on that in "What We Skipped").

Step 5: patch() — The Only Function That Touches the DOM

patch() receives the current real DOM node and the patch descriptor produced by diff(). Each case is surgical:

5

Implement patch() to apply descriptors to the real DOM

function patch(domNode, patchDesc) {
  switch (patchDesc.type) {
    case PATCH.SKIP:
      return domNode; // nothing to do
 
    case PATCH.REMOVE:
      domNode.parentNode?.removeChild(domNode);
      return null;
 
    case PATCH.REPLACE: {
      const newEl = createElement(patchDesc.newVnode);
      domNode.parentNode?.replaceChild(newEl, domNode);
      return newEl;
    }
 
    case PATCH.TEXT:
      domNode.textContent = patchDesc.text;
      return domNode;
 
    case PATCH.UPDATE: {
      if (patchDesc.propPatches)  applyPropPatches(domNode, patchDesc.propPatches);
      if (patchDesc.childPatches) applyChildPatches(domNode, patchDesc.childPatches);
      return domNode;
    }
  }
}
 
function applyPropPatches(el, propPatches) {
  for (const { key, value, oldValue } of propPatches) {
    // Detach the previous listener first, or both old and new fire.
    if (key.startsWith('on') && typeof oldValue === 'function') {
      el.removeEventListener(key.slice(2).toLowerCase(), oldValue);
    }
    setProp(el, key, value);
  }
}
 
function applyChildPatches(parentEl, childPatches) {
  let domIndex = 0;
 
  for (const childPatch of childPatches) {
    if (childPatch.type === 'ADD') {
      parentEl.appendChild(createElement(childPatch.vnode));
    } else if (childPatch.type === PATCH.REMOVE) {
      const child = parentEl.childNodes[domIndex];
      if (child) parentEl.removeChild(child);
      // Don't advance domIndex — removal shifts everything left
    } else {
      patch(parentEl.childNodes[domIndex], childPatch);
      domIndex++;
    }
  }
}

Notice that REMOVE doesn't advance domIndex. Removing a node shifts the live childNodes list, so the next patch still targets index domIndex — which now points to what was previously the next sibling.

The removeEventListener in applyPropPatches is the part that's easy to get wrong, and getting it wrong is invisible until it isn't. addEventListener doesn't replace anything; it appends. Swap onclick from f1 to f2 without detaching f1 and a single click runs both, forever, one extra copy per render. It cannot be fixed from inside applyPropPatches alone, which is why diffProps carries oldValueremoveEventListener needs the exact function reference that was originally attached, and by the time you're applying the patch the old vnode is out of scope.

Step 6: Wire It Together — A Counter Without React

6

Build a working counter component with mount() and setState()

// vdom.js — exports
function mount(vnode, container) {
  const el = createElement(vnode);
  container.appendChild(el);
  return el;
}
 
// ES modules, so the exact same file loads in Node and in the browser.
export { h, mount, createElement, diff, patch, PATCH };
// counter.js
import { h, mount, diff, patch } from './vdom.js';
 
let count = 0;
let currentVnode   = null;
let currentDomNode = null;
 
function view(n) {
  return h('div', { class: 'counter' },
    h('h1', {}, `Count: ${n}`),
    h('button', { onclick: decrement }, '−'),
    h('button', { onclick: increment }, '+'),
  );
}
 
function increment() { setState(count + 1); }
function decrement() { setState(count - 1); }
 
function setState(nextCount) {
  count = nextCount;
  const nextVnode = view(count);
  const patchDesc = diff(currentVnode, nextVnode);
  // patch() returns the node that now occupies this position — REPLACE hands
  // back a brand new element. Drop the return value and every later update
  // writes into a node that is no longer in the document.
  currentDomNode = patch(currentDomNode, patchDesc) ?? currentDomNode;
  currentVnode = nextVnode;
}
 
// First render
currentVnode   = view(count);
currentDomNode = mount(currentVnode, document.getElementById('app'));
<!-- index.html -->
<!DOCTYPE html>
<html>
<body>
  <div id="app"></div>
  <!-- type="module" is required: counter.js imports vdom.js. A plain
       <script src="counter.js"> dies immediately with
       "ReferenceError: require is not defined" (or with `import`,
       "Cannot use import statement outside a module") and #app stays empty. -->
  <script type="module" src="counter.js"></script>
</body>
</html>

Two bits of setup, both of which bite silently if you skip them:

  • Serve it over HTTP. npx serve . or Live Server. Module scripts are subject to CORS, and file:// origins fail the check — the browser refuses to load counter.js at all.
  • { "type": "module" } in package.json. Without it, node test-vdom.js treats the same vdom.js as CommonJS and rejects the export statement.

Open DevTools, expand the #app element, and click + or −. Only the text inside the <h1> changes; the <button> elements never flash. That's the diff doing its job — SKIP on both buttons, UPDATE → children → TEXT on the heading. Counting the calls in jsdom confirms it: a click produces zero createElement calls, zero removeChild calls, and one textContent write.

That holds only because increment and decrement are stable top-level function references, so diffProps sees oldProps.onclick === newProps.onclick and emits nothing. Write the handler inline as onclick: () => setState(count + 1) and every render produces a fresh function, a prop patch, and a detach/attach pair — correct, thanks to removeEventListener, but no longer free.

The reassignment of currentDomNode matters more than it looks. view() always returns a div here, so diff never returns REPLACE at the root and the original code appeared to work. Change the root tag once — a div that becomes a section — and patch replaces the element, returns the new one, and a version that ignored the return value keeps patching the detached original. The counter freezes with no error in the console.

Testing the Diff Logic in Node.js

h, diff, diffProps and diffChildren have no DOM dependency, so every descriptor they produce can be checked in plain Node. patch is the exception — it exists to touch document — and it gets its own suite in the next section. Don't conflate the two: a green descriptor test says the diff decided correctly, not that the page is correct.

One thing to settle first, though, because it invalidates everything else: not with console.assert.

🚨

console.assert(false, 'msg') does not throw in Node.js. It prints Assertion failed: msg to stderr and execution continues, so a file full of console.assert calls reaches its final console.log('All assertions passed.') and exits 0 no matter how broken the code under test is.

I checked this on a real regression: delete the "props that were removed" loop from diffProps, which stops removed attributes from ever being taken off the element. A console.assert harness that mounts <input type="text" disabled="true">, patches it to <input type="text"> and checks the resulting HTML prints exactly this:

Assertion failed: a removed prop is removed from the DOM
All assertions passed.

Exit code 0. In CI that is a green build. Use an assert that throws.

⚠️

While we're here: not every mutation that a test catches is a bug, and the difference matters when you're deciding what to assert. Changing the removed-prop patch from value: null to value: undefined fails test-vdom.js — but the DOM comes out identical, because patch doesn't test value === null. It tests isAbsent, which is v === null || v === undefined || v === false. So that assertion is pinning the shape of the descriptor, which is a legitimate thing to pin — a public-ish data structure other code destructures — but it is not protecting the user from anything. Know which of your tests are behavioural and which are structural; only the first kind tells you the page is right.

// test-vdom.js — pure descriptor tests, no DOM needed
import { h, diff, PATCH } from './vdom.js';
 
// console.assert() logs and keeps going in Node — a broken file would still
// print "All assertions passed". Throw instead.
const assert = (cond, msg) => { if (!cond) throw new Error('FAILED: ' + msg); };
 
// Same tree — should skip everything
assert(diff(h('div', { class: 'box' }, 'hello'),
            h('div', { class: 'box' }, 'hello')).type === PATCH.SKIP, 'identical trees → SKIP');
 
// Text changed
const p1 = diff(h('div', {}, 'hello'), h('div', {}, 'world'));
assert(p1.type === PATCH.UPDATE, 'text change → UPDATE');
assert(p1.childPatches[0].type === PATCH.TEXT, 'child text patch');
assert(p1.childPatches[0].text === 'world', 'patch carries the new text');
 
// Different tag — full replace
assert(diff(h('div', {}, 'x'), h('span', {}, 'x')).type === PATCH.REPLACE, 'tag change → REPLACE');
// Element ↔ text is also a replace
assert(diff(h('div', {}, h('span', {}, 'a')), h('div', {}, 'a'))
         .childPatches[0].type === PATCH.REPLACE, 'element → text → REPLACE');
 
// Prop added
const p2 = diff(h('input', { type: 'text' }), h('input', { type: 'text', disabled: 'true' }));
assert(p2.type === PATCH.UPDATE, 'prop add → UPDATE');
assert(p2.propPatches.length === 1 && p2.propPatches[0].key === 'disabled', 'exactly one prop patched');
 
// Prop removed
const p3 = diff(h('input', { type: 'text', disabled: 'true' }), h('input', { type: 'text' }));
assert(p3.propPatches[0].value === null, 'removed prop → null value');
 
// A prop named after an Object.prototype member is still just a prop.
// `key in newProps` answers "still present" here and emits no removal at all.
const p3b = diff(h('input', { toString: 'x' }), h('input', {}));
assert(p3b.propPatches?.[0]?.key === 'toString',
  'a prop named toString is removed like any other');
 
// A changed handler carries the old reference so patch() can detach it
const f1 = () => {}, f2 = () => {};
const p4 = diff(h('button', { onclick: f1 }), h('button', { onclick: f2 }));
assert(p4.propPatches[0].oldValue === f1, 'patch carries the previous handler');
 
// `key` is diff metadata, never a prop patch
assert(diff(h('li', { key: 'a' }, 'a'), h('li', { key: 'b' }, 'a')).type === PATCH.SKIP,
  'key change alone does not patch attributes');
 
// Child removed
const p5 = diff(h('ul', {}, h('li', {}, 'a'), h('li', {}, 'b')), h('ul', {}, h('li', {}, 'a')));
assert(p5.childPatches[1].type === PATCH.REMOVE, 'removed child → REMOVE');
 
// Conditional rendering: false / null / undefined children vanish instead of crashing
assert(h('div', {}, false && h('span', {}, 'x')).children.length === 0, 'false child dropped');
assert(h('div', {}, null, undefined, 'ok').children.length === 1, 'null/undefined children dropped');
 
// Verify the harness itself actually fails. If this prints nothing, your
// assert() is broken and every test above is decoration.
let harnessWorks = false;
try { assert(false, 'canary'); } catch { harnessWorks = true; }
assert(harnessWorks, 'assert() does not throw — fix it before trusting anything above');
 
console.log('All assertions passed.');

Run with node test-vdom.js. No test runner needed — a failure throws, the process exits non-zero, and the green line is unreachable.

Testing patch() Against a Real DOM

Everything above tests the decision. None of it tests the DOM write, which is where the interesting failures live: a detached node, a listener that fires twice, an attribute that never comes off. patch needs a document, so give it one — jsdom, not a hand-rolled fake, because the point is to catch the places where a real DOM behaves differently from what you assumed. npm i -D jsdom, then:

// test-patch.js — patch() is the only function that touches the DOM, so it
// needs a DOM.
import { JSDOM } from 'jsdom';
import { h, mount, createElement, diff, patch } from './vdom.js';
 
const assert = (cond, msg) => { if (!cond) throw new Error('FAILED: ' + msg); };
 
const dom = new JSDOM('<div id="root"></div>', { url: 'https://t.test/' });
globalThis.document = dom.window.document;
globalThis.window = dom.window;
 
// Mount `before`, patch it to `after`, hand back the host and the live node.
function roundTrip(before, after) {
  const host = document.createElement('div');
  let node = mount(before, host);
  node = patch(node, diff(before, after)) ?? null;
  return { host, node, html: host.innerHTML };
}
 
// --- SKIP does nothing at all
{
  const before = h('div', { class: 'box' }, 'hi');
  const { host, node, html } = roundTrip(before, h('div', { class: 'box' }, 'hi'));
  assert(html === '<div class="box">hi</div>', 'SKIP leaves the DOM alone: ' + html);
  assert(node === host.firstChild, 'SKIP returns the same node');
}
 
// --- TEXT rewrites only the text
{
  const { html } = roundTrip(h('div', {}, 'hello'), h('div', {}, 'world'));
  assert(html === '<div>world</div>', 'TEXT updates text content: ' + html);
}
 
// --- REPLACE swaps the element and returns the new one
{
  const before = h('div', { id: 'a' }, 'x');
  const { host, node, html } = roundTrip(before, h('section', { id: 'b' }, 'x'));
  assert(html === '<section id="b">x</section>', 'REPLACE swaps the element: ' + html);
  assert(node === host.firstChild && node.tagName === 'SECTION',
    'REPLACE returns the node that is now in the document');
}
 
// --- REMOVE detaches and returns null
{
  const before = h('div', {}, 'x');
  const host = document.createElement('div');
  const node = mount(before, host);
  const result = patch(node, diff(before, undefined));
  assert(host.innerHTML === '', 'REMOVE detaches the node: ' + host.innerHTML);
  assert(result === null, 'REMOVE returns null so the caller knows the node is gone');
}
 
// --- props: added, changed, removed
{
  const { html } = roundTrip(h('input', { type: 'text', title: 'old' }),
                             h('input', { type: 'text', title: 'new', name: 'q' }));
  assert(html === '<input type="text" title="new" name="q">', 'props added and changed: ' + html);
}
{
  const { html } = roundTrip(h('input', { type: 'text', disabled: 'true' }),
                             h('input', { type: 'text' }));
  assert(html === '<input type="text">', 'a removed prop is removed from the DOM: ' + html);
}
 
// --- a prop named after something on Object.prototype is still just a prop.
//     `key in newProps` walks the prototype chain and answers "yes, present"
//     for every one of these, so the removal is never emitted.
for (const name of ['toString', 'constructor', 'valueOf', 'hasOwnProperty']) {
  const { node } = roundTrip(h('input', { [name]: 'x' }), h('input', {}));
  assert(!node.hasAttribute(name),
    `a prop named "${name}" is removed like any other: ` + node.outerHTML);
}
 
// --- absent values mean "no attribute", not the string "false"/"null"
{
  const { html } = roundTrip(h('input', { disabled: true }),
                             h('input', { disabled: false }));
  assert(html === '<input>', 'disabled:false removes the attribute: ' + html);
}
{
  const { html } = roundTrip(h('input', { title: 'x' }), h('input', { title: null }));
  assert(html === '<input>', 'title:null removes the attribute: ' + html);
}
{
  const el = createElement(h('input', { disabled: true, title: undefined, hidden: false }));
  assert(el.outerHTML === '<input disabled="">',
    'true is presence, undefined/false are absence: ' + el.outerHTML);
}
 
// --- `key` never reaches the DOM
{
  const { node } = roundTrip(h('li', { key: 'a' }, 'a'), h('li', { key: 'b' }, 'a'));
  assert(!node.hasAttribute('key'), 'key is never written as an attribute: ' + node.outerHTML);
}
 
// --- swapping a handler detaches the old one
{
  let f1Calls = 0, f2Calls = 0;
  const f1 = () => { f1Calls += 1; };
  const f2 = () => { f2Calls += 1; };
  const before = h('button', { onclick: f1 }, 'go');
  const { node } = roundTrip(before, h('button', { onclick: f2 }, 'go'));
  node.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
  assert(f1Calls === 0, 'the replaced handler must not fire, fired ' + f1Calls + ' times');
  assert(f2Calls === 1, 'the new handler fires exactly once, fired ' + f2Calls + ' times');
}
// --- removing a handler detaches it too
{
  let calls = 0;
  const f = () => { calls += 1; };
  const { node } = roundTrip(h('button', { onclick: f }, 'go'), h('button', {}, 'go'));
  node.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
  assert(calls === 0, 'a removed handler must not fire, fired ' + calls + ' times');
}
 
// --- children: removing the middle one is where domIndex earns its keep
{
  const before = h('ul', {}, h('li', {}, 'a'), h('li', {}, 'b'), h('li', {}, 'c'));
  const { html } = roundTrip(before, h('ul', {}, h('li', {}, 'a'), h('li', {}, 'c')));
  assert(html === '<ul><li>a</li><li>c</li></ul>', 'positional diff after a removal: ' + html);
}
{
  const before = h('ul', {}, h('li', {}, 'a'), h('li', {}, 'b'), h('li', {}, 'c'));
  const { html } = roundTrip(before, h('ul', {}, h('li', {}, 'a')));
  assert(html === '<ul><li>a</li></ul>', 'two trailing removals: ' + html);
}
{
  const before = h('ul', {}, h('li', {}, 'a'));
  const { html } = roundTrip(before, h('ul', {}, h('li', {}, 'a'), h('li', {}, 'b')));
  assert(html === '<ul><li>a</li><li>b</li></ul>', 'ADD appends: ' + html);
}
 
// The canary. If assert() ever stops throwing, everything above is decoration.
let harnessWorks = false;
try { assert(false, 'canary'); } catch { harnessWorks = true; }
assert(harnessWorks, 'assert() does not throw — fix it before trusting anything above');
 
console.log('All patch assertions passed.');

Worth knowing what these buy you, so here are eight one-line mutations of vdom.js and which suite notices:

Mutationtest-vdom.jstest-patch.js
Object.hasOwn(newProps, key)key in newPropscaughtcaught
delete the removed-props loopcaughtcaught
removed-prop value: nullundefinedcaughtsurvives
delete the removeEventListener callsurvivescaught
isAbsentv === null onlysurvivescaught
REMOVE advances domIndexsurvivescaught
patch returns domNode from REPLACEsurvivescaught
isAttr() => true (let key through)caughtcaught

Four of the eight are invisible to the descriptor tests. That is not surprising in hindsight — they are all bugs in the DOM write, and the descriptor tests never write to a DOM — but it's the difference between "the diff is tested" and "the virtual DOM is tested". Before test-patch.js existed, patch had no tests at all, which is how the key in newProps bug lived in this article.

The third row is the mirror image, and the reason for the callout above: a mutation the descriptor test catches and the DOM does not care about.

What We Skipped

This is a working virtual DOM, built on the same create/diff/patch split Snabbdom uses, but intentionally scoped down. Here's what production implementations add on top:

  • Keyed diffing — this is the big one, and it's worth being precise about the cost rather than hand-waving. Our child diff matches by position, so prepending one item to a 1000-item list makes every subsequent item's text land one slot to the left. Instrumenting jsdom for that exact case gives createElement: 1, createTextNode: 1, appendChild: 2, removeChild: 0 — and 1000 textContent writes. So it does not rebuild the list, which is the usual claim; it rewrites the text of every node in it. A keyed diff would do one insertBefore and touch nothing else. Hold a reference to a list item's DOM node, reorder the list, and with positional diffing your reference now points at a node displaying someone else's content — which is why key exists, and why a keyed reconciler is the natural next thing to build.

  • Component state — We diff vnode trees. React's reconciler tracks component instances, so local state survives a re-render even as the vnode tree is rebuilt around it. Nothing in our four functions has anywhere to hang that state.

  • Prop values that aren't stringssetProp handles booleans, null and undefined. It does not handle a style object: h('div', { style: { color: 'red' } }) renders style="[object Object]". Frameworks special-case style, class/className, dangerouslySetInnerHTML, and the properties that must be set as properties rather than attributes — value, checked and selected on form controls, where the attribute only sets the default and the live value diverges from it the moment a user types.

  • Batched updates — Every setState call immediately diffs and patches. Real implementations batch updates into the next microtask with queueMicrotask() or animation frame with requestAnimationFrame(), collapsing multiple synchronous state changes into a single render pass.

  • SVG namespacedocument.createElement('svg') creates an HTML element, not an SVG one. SVG requires document.createElementNS('http://www.w3.org/2000/svg', tag). Our implementation silently creates broken SVG.

  • Fragments — No support for arrays of vnodes at the root (React's <>...</>). That needs a special node type with tag: null and children, plus handling in diff and patch.

The thing worth carrying away from this is a sharper reading of "the virtual DOM is fast". It isn't, particularly. Building a vnode tree on every state change is pure overhead that hand-written DOM code doesn't pay. What the virtual DOM buys is that you get to write the whole tree every time and still not throw away nodes that didn't change — you trade some allocation for never having to reason about incremental DOM updates yourself. Four things decide whether that trade holds:

  1. Is your diff keyed? Positional diffing over a reordered list is the difference between one insertBefore and a write to every node.
  2. Do you bail out early? The SKIP case is where the savings live. An implementation that always walks the whole tree has bought you nothing.
  3. Do you batch? One diff per state change means three setState calls in one event handler are three full passes. Coalescing them into one queueMicrotask is usually a bigger win than any diff optimisation.
  4. Is identity preserved where the user can see it? Focus, text selection, scroll position and playing media all live on DOM nodes. Recreating a node the user was interacting with is a visible bug, not a slow path.

Snabbdom is the readable production implementation of this exact architecture, and its init/patch split maps directly onto what we built. Vue 2's virtual DOM was originally forked from it; Vue 3's runtime is a rewrite whose compiler annotates each vnode with flags describing what can actually change, so the diff can skip work our version has to discover at runtime — a good illustration of the ceiling on any diff that only sees two trees and no history.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read