Rendering a vnode tree from scratch on every change would throw away the whole DOM each time — slow, and it loses focus, scroll, and selection. The virtual-DOM payoff comes from diffing: compare the new vnode tree against the old one and apply the minimum set of real-DOM changes. Same tag? Patch its props in place and recurse into children. Different tag or text? Replace just that node. This is React's reconciliation, in miniature.
Implement patch(parent, oldVNode, newVNode, index = 0) (with the given createElement/render). Mutate the DOM under parent so the child at index matches newVNode — updating props in place when the type is unchanged, replacing only when it truly changed, and recursing into children.
function patch(parent, oldVNode, newVNode, index = 0) {
// mutates parent.childNodes[index] to match newVNode
}
const oldV = h('div', { id: 'a' }, 'hi');
container.appendChild(render(oldV));
const node = container.firstChild;
patch(container, oldV, h('div', { id: 'b' }, 'hi'));
container.firstChild === node; // true — SAME node, only id changed to 'b'
changed(a, b) — true if the types differ, two primitives differ (text change), or two elements have different type (tag).You'll compare old vs new vnode at each position: add/remove/replace when the shape differs, or — when it's the same element — update its props in place and recurse into children by index, preserving unchanged nodes.
patch walks the old and new trees in lockstep and, at each position, picks the cheapest fix. If there's no old node, the new one is an addition — render and append it. If there's no new node, it's a removal. If old and new are "different enough" (a different tag, or one text differs from another), you can't reuse the node — replace it. But if they're the same element type, you keep the existing DOM node: update only the props that changed, then recurse into the children. That last case is the whole point — a prop or text change touches one node instead of rebuilding a subtree, so focus/scroll/selection survive.
At every position ask four questions in order: no old? → append. no new? → remove. changed? → replace. Otherwise same element → reconcile in place. "Changed" is deliberately shallow: different typeof, two differing primitives, or two elements with different tags. For the same-element case, diff the props (drop the gone ones, set the new/changed ones) and then diff the children positionally — patch the overlap, append the surplus new children, remove the surplus old ones. Because the same-type branch never recreates the node, its identity (and the state the browser keeps on it) is preserved.
The naive "just re-render" throws the whole node away every time:
function patchNaive(parent, oldVNode, newVNode, index = 0) {
parent.replaceChild(render(newVNode), parent.childNodes[index]); // always replace
}
It produces correct markup, but it's exactly what a virtual DOM exists to avoid: every update rebuilds the entire subtree, so an input loses focus mid-typing, a scrolled list jumps to the top, and event listeners are re-created wholesale. The value is in not replacing when you don't have to — diffing tells you the minimum, so a one-character text edit changes one text node and nothing else.
// Given: createElement + render (from the previous question).
function createElement(type, props, ...children) {
return { type, props: props || {}, children: children.flat() };
}
function render(vnode) {
if (typeof vnode === 'string' || typeof vnode === 'number') {
return document.createTextNode(String(vnode));
}
const el = document.createElement(vnode.type);
for (const [key, value] of Object.entries(vnode.props)) applyProp(el, key, value);
for (const child of vnode.children) el.appendChild(render(child));
return el;
}
function changed(a, b) {
if (typeof a !== typeof b) return true;
if (typeof a !== 'object') return a !== b; // text/number differ
return a.type !== b.type; // element tag differs
}
function applyProp(el, key, value) {
if (key.startsWith('on') && typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else if (key === 'className') {
el.setAttribute('class', value);
} else {
el.setAttribute(key, value);
}
}
function removePropFromEl(el, key, oldValue) {
if (key.startsWith('on') && typeof oldValue === 'function') {
el.removeEventListener(key.slice(2).toLowerCase(), oldValue);
} else if (key === 'className') {
el.removeAttribute('class');
} else {
el.removeAttribute(key);
}
}
function updateProps(el, oldProps, newProps) {
for (const key of Object.keys(oldProps)) {
if (!(key in newProps)) removePropFromEl(el, key, oldProps[key]); // gone
}
for (const key of Object.keys(newProps)) {
if (oldProps[key] !== newProps[key]) { // new/changed
if (key.startsWith('on') && typeof oldProps[key] === 'function') {
el.removeEventListener(key.slice(2).toLowerCase(), oldProps[key]);
}
applyProp(el, key, newProps[key]);
}
}
}
function patch(parent, oldVNode, newVNode, index = 0) {
const existing = parent.childNodes[index];
if (newVNode == null) { // removal
if (existing) parent.removeChild(existing);
return;
}
if (oldVNode == null) { // addition
parent.appendChild(render(newVNode));
return;
}
if (changed(oldVNode, newVNode)) { // replacement
parent.replaceChild(render(newVNode), existing);
return;
}
if (typeof newVNode === 'object') { // same element: reconcile in place
updateProps(existing, oldVNode.props || {}, newVNode.props || {});
const oldCh = oldVNode.children || [];
const newCh = newVNode.children || [];
const min = Math.min(oldCh.length, newCh.length);
for (let i = 0; i < min; i++) patch(existing, oldCh[i], newCh[i], i); // overlap
for (let i = min; i < newCh.length; i++) existing.appendChild(render(newCh[i])); // surplus new
for (let i = oldCh.length - 1; i >= min; i--) { // surplus old
if (existing.childNodes[i]) existing.removeChild(existing.childNodes[i]);
}
}
}
module.exports = { createElement, render, patch };
patch handles the four cases in order. changed is the shallow test that decides reuse-vs-replace. updateProps reconciles attributes/listeners on the existing node — removing props absent in the new vnode, setting props that are new or differ (swapping the old event listener first). The children loop patches each overlapping index (recursing, so deep changes are handled), appends the extra new children, and removes surplus old ones from the end (so index shifts don't corrupt earlier positions). The same-element branch never calls render on the node itself, so its DOM identity — and the browser state attached to it — is preserved across the update.
patch(container, h('ul', null, li('a'), li('b')), h('ul', null, li('a'), li('B'), li('c'))):
ul, same tag → not changed → reconcile in place (the <ul> node is kept). No props.patch(ul, li('a'), li('a'), 0): same li, child 'a' vs 'a' → not changed → nothing. The first <li> keeps its identity. patch(ul, li('b'), li('B'), 1): same li, child 'b' vs 'B' differ → replace that text node only.li('c') has no old counterpart → render and append.<ul><li>a</li><li>B</li><li>c</li></ul>, with the first <li> untouched and only the changed text and the added node created.childNodes[i] shifts later indices; remove from the end backward (or track an offset).updateProps.changed too deep or too shallow — comparing whole prop objects over-replaces; comparing only tags misses text changes. Type + tag + primitive-value is the right granularity.key prop lets you match children by identity and move nodes instead of rebuilding — essential for lists.type that's a function (component) or an array (fragment) means a vnode maps to zero-or-many DOM nodes, which complicates the index bookkeeping.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Rendering a vnode tree from scratch on every change would throw away the whole DOM each time — slow, and it loses focus, scroll, and selection. The virtual-DOM payoff comes from diffing: compare the new vnode tree against the old one and apply the minimum set of real-DOM changes. Same tag? Patch its props in place and recurse into children. Different tag or text? Replace just that node. This is React's reconciliation, in miniature.
Implement patch(parent, oldVNode, newVNode, index = 0) (with the given createElement/render). Mutate the DOM under parent so the child at index matches newVNode — updating props in place when the type is unchanged, replacing only when it truly changed, and recursing into children.
function patch(parent, oldVNode, newVNode, index = 0) {
// mutates parent.childNodes[index] to match newVNode
}
const oldV = h('div', { id: 'a' }, 'hi');
container.appendChild(render(oldV));
const node = container.firstChild;
patch(container, oldV, h('div', { id: 'b' }, 'hi'));
container.firstChild === node; // true — SAME node, only id changed to 'b'
changed(a, b) — true if the types differ, two primitives differ (text change), or two elements have different type (tag).You'll compare old vs new vnode at each position: add/remove/replace when the shape differs, or — when it's the same element — update its props in place and recurse into children by index, preserving unchanged nodes.
patch walks the old and new trees in lockstep and, at each position, picks the cheapest fix. If there's no old node, the new one is an addition — render and append it. If there's no new node, it's a removal. If old and new are "different enough" (a different tag, or one text differs from another), you can't reuse the node — replace it. But if they're the same element type, you keep the existing DOM node: update only the props that changed, then recurse into the children. That last case is the whole point — a prop or text change touches one node instead of rebuilding a subtree, so focus/scroll/selection survive.
At every position ask four questions in order: no old? → append. no new? → remove. changed? → replace. Otherwise same element → reconcile in place. "Changed" is deliberately shallow: different typeof, two differing primitives, or two elements with different tags. For the same-element case, diff the props (drop the gone ones, set the new/changed ones) and then diff the children positionally — patch the overlap, append the surplus new children, remove the surplus old ones. Because the same-type branch never recreates the node, its identity (and the state the browser keeps on it) is preserved.
The naive "just re-render" throws the whole node away every time:
function patchNaive(parent, oldVNode, newVNode, index = 0) {
parent.replaceChild(render(newVNode), parent.childNodes[index]); // always replace
}
It produces correct markup, but it's exactly what a virtual DOM exists to avoid: every update rebuilds the entire subtree, so an input loses focus mid-typing, a scrolled list jumps to the top, and event listeners are re-created wholesale. The value is in not replacing when you don't have to — diffing tells you the minimum, so a one-character text edit changes one text node and nothing else.
// Given: createElement + render (from the previous question).
function createElement(type, props, ...children) {
return { type, props: props || {}, children: children.flat() };
}
function render(vnode) {
if (typeof vnode === 'string' || typeof vnode === 'number') {
return document.createTextNode(String(vnode));
}
const el = document.createElement(vnode.type);
for (const [key, value] of Object.entries(vnode.props)) applyProp(el, key, value);
for (const child of vnode.children) el.appendChild(render(child));
return el;
}
function changed(a, b) {
if (typeof a !== typeof b) return true;
if (typeof a !== 'object') return a !== b; // text/number differ
return a.type !== b.type; // element tag differs
}
function applyProp(el, key, value) {
if (key.startsWith('on') && typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else if (key === 'className') {
el.setAttribute('class', value);
} else {
el.setAttribute(key, value);
}
}
function removePropFromEl(el, key, oldValue) {
if (key.startsWith('on') && typeof oldValue === 'function') {
el.removeEventListener(key.slice(2).toLowerCase(), oldValue);
} else if (key === 'className') {
el.removeAttribute('class');
} else {
el.removeAttribute(key);
}
}
function updateProps(el, oldProps, newProps) {
for (const key of Object.keys(oldProps)) {
if (!(key in newProps)) removePropFromEl(el, key, oldProps[key]); // gone
}
for (const key of Object.keys(newProps)) {
if (oldProps[key] !== newProps[key]) { // new/changed
if (key.startsWith('on') && typeof oldProps[key] === 'function') {
el.removeEventListener(key.slice(2).toLowerCase(), oldProps[key]);
}
applyProp(el, key, newProps[key]);
}
}
}
function patch(parent, oldVNode, newVNode, index = 0) {
const existing = parent.childNodes[index];
if (newVNode == null) { // removal
if (existing) parent.removeChild(existing);
return;
}
if (oldVNode == null) { // addition
parent.appendChild(render(newVNode));
return;
}
if (changed(oldVNode, newVNode)) { // replacement
parent.replaceChild(render(newVNode), existing);
return;
}
if (typeof newVNode === 'object') { // same element: reconcile in place
updateProps(existing, oldVNode.props || {}, newVNode.props || {});
const oldCh = oldVNode.children || [];
const newCh = newVNode.children || [];
const min = Math.min(oldCh.length, newCh.length);
for (let i = 0; i < min; i++) patch(existing, oldCh[i], newCh[i], i); // overlap
for (let i = min; i < newCh.length; i++) existing.appendChild(render(newCh[i])); // surplus new
for (let i = oldCh.length - 1; i >= min; i--) { // surplus old
if (existing.childNodes[i]) existing.removeChild(existing.childNodes[i]);
}
}
}
module.exports = { createElement, render, patch };
patch handles the four cases in order. changed is the shallow test that decides reuse-vs-replace. updateProps reconciles attributes/listeners on the existing node — removing props absent in the new vnode, setting props that are new or differ (swapping the old event listener first). The children loop patches each overlapping index (recursing, so deep changes are handled), appends the extra new children, and removes surplus old ones from the end (so index shifts don't corrupt earlier positions). The same-element branch never calls render on the node itself, so its DOM identity — and the browser state attached to it — is preserved across the update.
patch(container, h('ul', null, li('a'), li('b')), h('ul', null, li('a'), li('B'), li('c'))):
ul, same tag → not changed → reconcile in place (the <ul> node is kept). No props.patch(ul, li('a'), li('a'), 0): same li, child 'a' vs 'a' → not changed → nothing. The first <li> keeps its identity. patch(ul, li('b'), li('B'), 1): same li, child 'b' vs 'B' differ → replace that text node only.li('c') has no old counterpart → render and append.<ul><li>a</li><li>B</li><li>c</li></ul>, with the first <li> untouched and only the changed text and the added node created.childNodes[i] shifts later indices; remove from the end backward (or track an offset).updateProps.changed too deep or too shallow — comparing whole prop objects over-replaces; comparing only tags misses text changes. Type + tag + primitive-value is the right granularity.key prop lets you match children by identity and move nodes instead of rebuilding — essential for lists.type that's a function (component) or an array (fragment) means a vnode maps to zero-or-many DOM nodes, which complicates the index bookkeeping.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.