The virtual DOM is one of those things everyone talks about but few actually understand. I wanted to see how simple it could be. Turns out, the core idea fits in ~100 lines.
Virtual nodes
A virtual node is just a plain object. The h function creates them.
function h(type, props = {}, ...children) {
return { type, props, children: children.flat() };
}So when you write:
h("div", { class: "container" },
h("h1", {}, "Hello"),
h("p", {}, "World")
)You get a tree of plain objects. No magic, no classes. Just data.
Rendering to real DOM
The render function walks the virtual tree and creates real DOM elements.
function render(vnode) {
if (typeof vnode === "string") {
return document.createTextNode(vnode);
}
const el = document.createElement(vnode.type);
for (const [key, val] of Object.entries(vnode.props)) {
if (key.startsWith("on")) {
el.addEventListener(key.slice(2).toLowerCase(), val);
} else if (key === "style" && typeof val === "object") {
for (const [styleKey, styleVal] of Object.entries(val)) {
el.style[styleKey] = styleVal;
}
} else {
el.setAttribute(key, val);
}
}
for (const child of vnode.children) {
el.appendChild(render(child));
}
return el;
}Event listeners get special handling. Props starting with "on" get converted to addEventListener calls. Style objects get applied as inline styles. Everything else becomes an attribute.
The diff algorithm
This is the interesting part. Instead of re-rendering the entire DOM, you compare the old virtual tree with the new one and only update what changed.
function diff(parent, oldVNode, newVNode, index = 0) {
const el = parent.childNodes[index];
// No old node, create new
if (!oldVNode) {
parent.appendChild(render(newVNode));
return;
}
// No new node, remove old
if (!newVNode) {
parent.removeChild(el);
return;
}
// Different type or text changed, replace
if (
typeof oldVNode !== typeof newVNode ||
(typeof newVNode === "string" && oldVNode !== newVNode) ||
oldVNode.type !== newVNode.type
) {
parent.replaceChild(render(newVNode), el);
return;
}
// Same element, diff props and children
if (typeof newVNode !== "string") {
const allProps = new Set([
...Object.keys(oldVNode.props || {}),
...Object.keys(newVNode.props || {}),
]);
for (const key of allProps) {
const oldVal = oldVNode.props[key];
const newVal = newVNode.props[key];
if (oldVal !== newVal) {
if (key.startsWith("on")) {
el.removeEventListener(key.slice(2).toLowerCase(), oldVal);
el.addEventListener(key.slice(2).toLowerCase(), newVal);
} else {
el.setAttribute(key, newVal);
}
}
}
// Diff children recursively
const max = Math.max(
oldVNode.children.length,
newVNode.children.length
);
for (let i = 0; i < max; i++) {
diff(el, oldVNode.children[i], newVNode.children[i], i);
}
}
}Four cases: node added, node removed, node replaced, node updated. For updates, diff the props first, then recursively diff each child.
What I learned
- The virtual DOM is not about being faster than the real DOM. It is about computing the minimum set of changes needed, so you do not have to figure it out yourself.
- The diff algorithm without keys is O(n) but naive. It compares children by index, which means reordering a list causes every item to be replaced. That is why React needs keys.
- Event listeners need special handling during diffing. You cannot just setAttribute for onClick. You have to remove the old listener and add the new one.
- The real complexity in React is not the virtual DOM. It is the scheduler, the fiber architecture, and concurrent rendering. The virtual DOM itself is the simple part.
~100 lines of JavaScript. No build step, no dependencies.