Implement a Virtual DOM
Interview Question: "Implement a simplified Virtual DOM with createElement, render, and diff."
Step 1: createElement ā Build VNode Objects
function createElement(type, props = {}, ...children) {
return {
type,
props: props || {},
children: children
.flat()
.map((child) =>
typeof child === "object" ? child : createTextNode(child)
),
};
}
function createTextNode(text) {
return {
type: "TEXT",
props: {},
children: [],
value: String(text),
};
}Usage ā similar to JSX compilation output:
const vdom = createElement(
"div",
{ id: "app", className: "container" },
createElement("h1", null, "Hello World"),
createElement(
"ul",
null,
createElement("li", { key: "1" }, "Item 1"),
createElement("li", { key: "2" }, "Item 2")
)
);"This is what JSX compiles to.
<div id="app">becomescreateElement('div', { id: 'app' }, ...). The result is a plain JavaScript object tree ā cheap to create and compare."
Step 2: render ā Turn VNodes into Real DOM
function render(vnode) {
if (vnode.type === "TEXT") {
return document.createTextNode(vnode.value);
}
const el = document.createElement(vnode.type);
for (const [key, value] of Object.entries(vnode.props)) {
setProp(el, key, value);
}
for (const child of vnode.children) {
el.appendChild(render(child));
}
return el;
}
function setProp(el, key, value) {
if (key === "className") {
el.setAttribute("class", value);
} else if (key.startsWith("on")) {
const event = key.slice(2).toLowerCase();
el.addEventListener(event, value);
} else if (key === "style" && typeof value === "object") {
Object.assign(el.style, value);
} else {
el.setAttribute(key, value);
}
}"
renderrecursively walks the VNode tree and creates corresponding DOM nodes. This is the initial mount ā the expensive part. After this, we want to minimize DOM operations by diffing."
Step 3: diff ā Compare Old and New VNode Trees
const PATCH_TYPES = {
CREATE: "CREATE",
REMOVE: "REMOVE",
REPLACE: "REPLACE",
UPDATE: "UPDATE",
};
function diff(oldNode, newNode) {
if (!oldNode) {
return { type: PATCH_TYPES.CREATE, newNode };
}
if (!newNode) {
return { type: PATCH_TYPES.REMOVE };
}
if (changed(oldNode, newNode)) {
return { type: PATCH_TYPES.REPLACE, newNode };
}
if (newNode.type !== "TEXT") {
const patchProps = diffProps(oldNode.props, newNode.props);
const patchChildren = diffChildren(oldNode.children, newNode.children);
if (patchProps.length === 0 && patchChildren.every((p) => !p)) {
return null; // no changes
}
return {
type: PATCH_TYPES.UPDATE,
patchProps,
patchChildren,
};
}
return null;
}
function changed(a, b) {
return (
a.type !== b.type ||
(a.type === "TEXT" && a.value !== b.value)
);
}
function diffProps(oldProps, newProps) {
const patches = [];
for (const [key, value] of Object.entries(newProps)) {
if (oldProps[key] !== value) {
patches.push({ key, value });
}
}
for (const key of Object.keys(oldProps)) {
if (!(key in newProps)) {
patches.push({ key, value: undefined });
}
}
return patches;
}
function diffChildren(oldChildren, newChildren) {
const patches = [];
const maxLen = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < maxLen; i++) {
patches.push(diff(oldChildren[i], newChildren[i]));
}
return patches;
}Step 4: patch ā Apply Diff to Real DOM
function patch(parent, patchObj, index = 0) {
if (!patchObj) return;
const el = parent.childNodes[index];
switch (patchObj.type) {
case PATCH_TYPES.CREATE: {
parent.appendChild(render(patchObj.newNode));
break;
}
case PATCH_TYPES.REMOVE: {
parent.removeChild(el);
break;
}
case PATCH_TYPES.REPLACE: {
parent.replaceChild(render(patchObj.newNode), el);
break;
}
case PATCH_TYPES.UPDATE: {
for (const { key, value } of patchObj.patchProps) {
if (value === undefined) {
el.removeAttribute(key);
} else {
setProp(el, key, value);
}
}
for (let i = 0; i < patchObj.patchChildren.length; i++) {
patch(el, patchObj.patchChildren[i], i);
}
break;
}
}
}Putting It All Together
let currentTree = createElement(
"div",
{ id: "app" },
createElement("h1", null, "Count: 0"),
createElement("button", { onClick: () => update() }, "Increment")
);
let count = 0;
const root = document.getElementById("root");
root.appendChild(render(currentTree));
function update() {
count++;
const newTree = createElement(
"div",
{ id: "app" },
createElement("h1", null, `Count: ${count}`),
createElement("button", { onClick: () => update() }, "Increment")
);
const patches = diff(currentTree, newTree);
patch(root, patches, 0);
currentTree = newTree;
}"Each update: create a new VNode tree, diff against the old one, patch only what changed. The h1 text updates, but the div and button are untouched ā minimal DOM operations."
Why React Moved Beyond Simple Diffing
"This naive implementation has limitations that React's Fiber architecture solves:"
- Synchronous recursion ā our diff blocks the main thread for large trees. Fiber uses an interruptible work loop with
requestIdleCallbacksemantics, yielding to the browser between units of work. - No priorities ā we treat all updates equally. Fiber assigns priorities (user input > data fetch > offscreen render) and can interrupt low-priority work for urgent updates.
- Linear child diffing ā we diff children by index (O(n) but misses reordering). React uses keys to identify children and handle reordering, insertions, and deletions efficiently.
Key Optimizations
Keyed Children
// Without keys: removing first item re-renders ALL items
// [A, B, C] ā [B, C] = replace AāB, replace BāC, remove C (3 ops)
// With keys: removing first item is a single removal
// [key:a, key:b, key:c] ā [key:b, key:c] = remove key:a (1 op)Batching Updates
let pendingUpdates = [];
let isBatching = false;
function batchUpdate(updateFn) {
pendingUpdates.push(updateFn);
if (!isBatching) {
isBatching = true;
requestAnimationFrame(() => {
for (const fn of pendingUpdates) fn();
pendingUpdates = [];
isBatching = false;
reconcile(); // single diff + patch
});
}
}"Batching collects multiple state updates into a single reconciliation pass. Without it, ten state changes would trigger ten diffs and ten DOM patches."
What Interviewers Look For
- Clear separation ā createElement (describe) ā render (mount) ā diff (compare) ā patch (update)
- Understanding the "why" ā DOM operations are expensive, object comparison is cheap
- Awareness of limitations ā this is O(n) tree comparison, not O(n³) naive tree diff
- Fiber knowledge ā knowing why React moved beyond synchronous recursive diffing
- Key optimization ā understanding why keys exist and what happens without them
Common Mistakes
- Mutating the old VNode tree instead of comparing against a new one
- Forgetting to handle text nodes as a special case
- Not handling removed props (attribute still on the DOM element)
- Comparing children by index only without mentioning keys
- Not explaining why this matters (the performance argument)
Red Flags
- Cannot articulate why Virtual DOM exists (what problem it solves)
- Doesn't know the difference between this and React's actual reconciler
- Thinks Virtual DOM is always faster than direct DOM manipulation (it's not ā it's a trade-off for declarative programming)
- Cannot explain what Fiber changed and why