classnames()
beginnerRebuild the classnames utility: strings, arrays, objects, and nested combinations.
Implement virtual DOM diffing and patching in JavaScript — the replace-vs-recurse rule, props diffing, index-shift-safe children updates, and why keys exist.
Given the old and new virtual trees (the
{ type, props, children }objects from part one), update the real DOM to match the new tree — touching as little of the DOM as possible.
This is the payoff of the trilogy: serialize gave us UI-as-data, deserialize gave us render, and diffing is what makes the whole detour worth it — re-rendering by rebuilding everything works, but it destroys focus, selection, scroll positions, and video playback state along with the nodes. Reconciliation exists to preserve the DOM nodes that didn't change.
The general tree-edit-distance problem is O(n³). React's insight — and what you're implementing — is a set of heuristics that get to O(n): different type → replace the subtree; same type → patch in place and recurse; children → compare positionally (keys upgrade this — see below).
render here is part two's deserialize.
function changed(a, b) {
if (a.type !== b.type) return true; // div → span: replace
if (a.type === "text") {
return a.props.nodeValue !== b.props.nodeValue; // text: compare content
}
return false; // same type: patch in place
}
function patchProps(el, oldProps, newProps) {
for (const name of Object.keys(oldProps)) {
if (!(name in newProps)) el.removeAttribute(name); // removed props
}
for (const [name, value] of Object.entries(newProps)) {
if (oldProps[name] !== value) el.setAttribute(name, value); // added/changed
}
}
function patch(parent, oldVNode, newVNode, index = 0) {
const el = parent.childNodes[index];
if (oldVNode === undefined) { // node added
parent.appendChild(render(newVNode));
return;
}
if (newVNode === undefined) { // node removed
parent.removeChild(el);
return;
}
if (changed(oldVNode, newVNode)) { // replace the whole subtree
parent.replaceChild(render(newVNode), el);
return;
}
if (newVNode.type !== "text") { // same element: patch & recurse
patchProps(el, oldVNode.props, newVNode.props);
patchChildren(el, oldVNode.children, newVNode.children);
}
}
function patchChildren(el, oldChildren, newChildren) {
const common = Math.min(oldChildren.length, newChildren.length);
// 1. patch the overlap, position by position
for (let i = 0; i < common; i++) {
patch(el, oldChildren[i], newChildren[i], i);
}
// 2. append new extras
for (let i = common; i < newChildren.length; i++) {
el.appendChild(render(newChildren[i]));
}
// 3. remove old extras — BACKWARDS, so indices don't shift under us
for (let i = oldChildren.length - 1; i >= common; i--) {
el.removeChild(el.childNodes[i]);
}
}// tiny helpers for building vnodes by hand:
const h = (type, props = {}, children = []) => ({ type, props, children });
const text = (nodeValue) => ({ type: "text", props: { nodeValue }, children: [] });
const oldV = h("ul", {}, [
h("li", { class: "item" }, [text("Alpha")]),
h("li", {}, [text("Beta")]),
]);
const newV = h("ul", { class: "list" }, [
h("li", { class: "item done" }, [text("Alpha")]),
h("li", {}, [text("Bravo")]),
h("li", {}, [text("Charlie")]),
]);
patch(container, oldV, newV);
// ul: same type → patchProps sets class="list" (1 attribute write)
// li[0]: same type → class "item" → "item done" (1 attribute write)
// text: "Alpha" === "Alpha" → NOTHING happens (0 writes)
// li[1]: same type, props equal → recurse
// text: "Beta" → "Bravo" → replace text node (1 node swap)
// li[2]: old undefined → append rendered <li>Charlie</li> (1 append)
// TOTAL: 4 DOM mutations. Rebuild-everything: ~9 node creations + teardown.The zero-write line for unchanged text is the whole sales pitch — and the <li>Alpha</li> element object identity is preserved: if it held focus or selection, it still does.
patchChildren. Removing childNodes[i] shifts every later index down; a forward loop removes the wrong nodes (delete index 2, old index 3 becomes 2, loop moves to 3 — one survives). Iterating removals from the end is the classic index-invalidation fix, and it's the planted bug in this question.div → span change could theoretically keep children; the heuristic says subtrees of different types are different things — cheap, and right in practice. This single assumption is what collapses O(n³) to O(n); name it as the trade.patchProps diffs three ways — removed, added, changed. Forgetting the removal pass leaves stale attributes (class from a previous state lingering) — the second most-planted bug.changed treats two text nodes with equal content as unchanged, which is the common case in any list re-render.Positional comparison is O(n) but identity-blind. Prepend one item to a 500-row list:
old: [A, B, C] new: [X, A, B, C]
positional diff: patch A→X, B→A, C→B, append C → rewrites EVERY row
keyed diff: insert X at 0 → one insertionWorse than performance: because element objects are reused positionally, row state that lives in the DOM (focus, input values not mirrored to data, CSS transitions) attaches to the wrong logical item after the shift. That's precisely the bug class React's key warning prevents — keys let the differ match children by identity (build an old-key → element map, reorder/insert/remove instead of rewrite), turning the child diff into the move-minimizing algorithm every real framework ships (React's two-pass approach, Vue's double-ended diff, longest-increasing-subsequence tricks in Inferno/Vue 3). Implementing keyed diff is the natural 20-minute extension; explaining the focus-attached-to-wrong-row bug is the part interviewers actually require.
patchProps writes attributes; live form state (value, checked) needs property writes and doesn't round-trip through attributes — part two's layer distinction returns with interest. Real frameworks maintain a per-prop policy table.replaceChild on the root — if the root types differ, parent is the container; the index = 0 default handles it, but only if the container holds exactly the tree — mounting discipline from part two matters here.insertBefore), remove leftovers; discuss why index-as-key defeats the entire mechanism (it is positional diff with extra steps) — the code-review-famous anti-pattern, explained from first principles.{ op, path, ... } lists enable batching, undo, and cross-boundary shipping (worker-side diff, main-thread apply) — the Immer-patches idea meeting the DOM.type is a function, call it with props to get the subtree, compare the results — and memoize the call when props are shallow-equal: you've just derived React.memo from memoizeLast.Rebuild the classnames utility: strings, arrays, objects, and nested combinations.
Rebuild real DOM from the virtual tree, completing the render pipeline.
A mini Redux store with Immer-style draft mutations — reducers, subscriptions, and immutability.
Turn real DOM into a plain-object tree — the first half of understanding how React represents UI.