Building a Simple Virtual DOM — Complete Guide
In this tutorial, you will learn about Building a Simple Virtual Dom. We cover key concepts, practical examples, and best practices to help you master this topic.
Building a simple Virtual DOM from scratch teaches how createElement, diffing, patching, and reconciliation work, demystifying the internals of modern UI frameworks.
What You'll Learn
- How to implement a createElement function (h)
- How to build a render function that converts vnodes to DOM
- How to implement a diff algorithm
- How to build a patch function that applies minimal DOM updates
Why It Matters
Understanding Virtual DOM internals helps you debug framework issues, choose the right framework, and appreciate the engineering behind modern UI development.
flowchart LR A[createElement] --> B[Virtual Node] B --> C[render] C --> D[Real DOM] D --> E[User interaction] E --> F[New virtual tree] F --> G[diff] G --> H[patches] H --> I[patch] I --> D
Step 1: The createElement Function (h)
The h function creates virtual nodes (vnodes). It is the foundation of the Virtual DOM.
// Virtual node structure:
// {
// type: 'div' | 'span' | function, // Element type
// props: { className, style, ... }, // Attributes and properties
// children: [vnode, vnode, ...], // Child virtual nodes
// key: 'unique-id', // Optional key for reconciliation
// }
function h(type, props, ...children) {
// Flatten children array (handle nested arrays)
const flatChildren = children.flat(Infinity).filter(child => {
return child !== null && child !== undefined && child !== false;
});
// Process props to handle special cases
const processedProps = { ...props };
// Store children in props for easy access
if (flatChildren.length > 0) {
processedProps.children = flatChildren;
}
return {
type,
props: processedProps,
key: props?.key || null,
_isVNode: true // Internal marker for type checking
};
}
// Usage:
const vnode = h('div', { className: 'container' },
h('h1', null, 'Hello'),
h('p', { style: { color: 'red' } }, 'World')
);
// Produces:
// {
// type: 'div',
// key: null,
// props: {
// className: 'container',
// children: [
// { type: 'h1', props: { children: ['Hello'] }, key: null, _isVNode: true },
// { type: 'p', props: { style: { color: 'red' }, children: ['World'] }, key: null, _isVNode: true }
// ]
// },
// _isVNode: true
// }
Step 2: Rendering VNodes to the DOM
The render function converts a virtual node tree into real DOM elements.
function render(vnode, container) {
const dom = createDomElement(vnode);
container.appendChild(dom);
return dom;
}
function createDomElement(vnode) {
if (typeof vnode === 'string' || typeof vnode === 'number') {
// Text node
return document.createTextNode(String(vnode));
}
if (typeof vnode.type === 'function') {
// Component: call the function to get its vnode
const componentVNode = vnode.type(vnode.props);
return createDomElement(componentVNode);
}
// HTML element
const dom = document.createElement(vnode.type);
// Set attributes and properties
if (vnode.props) {
for (const [key, value] of Object.entries(vnode.props)) {
if (key === 'children' || key === 'key') continue;
if (key === 'className') {
dom.setAttribute('class', value);
} else if (key === 'style' && typeof value === 'object') {
Object.assign(dom.style, value);
} else if (key.startsWith('on') && typeof value === 'function') {
// Event listener
const eventType = key.slice(2).toLowerCase();
dom.addEventListener(eventType, value);
} else if (key === 'dangerouslySetInnerHTML') {
dom.innerHTML = value.__html;
} else {
dom.setAttribute(key, value);
}
}
}
// Render children
if (vnode.props && vnode.props.children) {
const children = Array.isArray(vnode.props.children)
? vnode.props.children
: [vnode.props.children];
children.forEach(child => {
dom.appendChild(createDomElement(child));
});
}
return dom;
}
// Usage:
const container = document.getElementById('app');
render(
h('div', { className: 'app' },
h('h1', null, 'My Virtual DOM'),
h('p', null, 'Rendered without a framework!')
),
container
);
// Expected output:
// <div class="app">
// <h1>My Virtual DOM</h1>
// <p>Rendered without a framework!</p>
// </div>
Step 3: The Diff Algorithm
Compare two virtual trees and produce patches.
function diff(oldVNode, newVNode) {
// No old node: create
if (!oldVNode) {
return { type: 'CREATE', newVNode };
}
// No new node: delete
if (!newVNode) {
return { type: 'DELETE' };
}
// Text node changes
if (typeof oldVNode === 'string' && typeof newVNode === 'string') {
if (oldVNode !== newVNode) {
return { type: 'TEXT', newText: newVNode };
}
return null; // No change
}
// Different types: replace
if (oldVNode.type !== newVNode.type) {
return { type: 'REPLACE', newVNode };
}
// Same type: diff props and children
const patches = [];
// Diff props
const propPatches = diffProps(oldVNode.props, newVNode.props);
if (propPatches && propPatches.length > 0) {
patches.push({ type: 'PROPS', patches: propPatches });
}
// Diff children
const childPatches = diffChildren(
oldVNode.props?.children || [],
newVNode.props?.children || []
);
if (childPatches && childPatches.length > 0) {
patches.push({ type: 'CHILDREN', patches: childPatches });
}
return patches.length > 0 ? { type: 'UPDATE', patches } : null;
}
function diffProps(oldProps, newProps) {
const patches = [];
const oldKeys = oldProps ? Object.keys(oldProps).filter(k => k !== 'children' && k !== 'key') : [];
const newKeys = newProps ? Object.keys(newProps).filter(k => k !== 'children' && k !== 'key') : [];
// Check for removed and changed props
oldKeys.forEach(key => {
if (!newProps || !(key in newProps)) {
patches.push({ type: 'REMOVE_PROP', key });
} else if (oldProps[key] !== newProps[key]) {
patches.push({ type: 'SET_PROP', key, value: newProps[key] });
}
});
// Check for new props
newKeys.forEach(key => {
if (!oldProps || !(key in oldProps)) {
patches.push({ type: 'SET_PROP', key, value: newProps[key] });
}
});
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({
index: i,
patch: diff(oldChildren[i], newChildren[i])
});
}
return patches.filter(p => p.patch !== null);
}
Step 4: Applying Patches to the DOM
The patch function applies diff output to the real DOM.
function patch(dom, patches) {
if (!patches) return dom;
switch (patches.type) {
case 'CREATE': {
const newDom = createDomElement(patches.newVNode);
dom.parentNode?.replaceChild(newDom, dom);
return newDom;
}
case 'DELETE': {
dom.remove();
return null;
}
case 'TEXT': {
dom.textContent = patches.newText;
return dom;
}
case 'REPLACE': {
const newDom = createDomElement(patches.newVNode);
dom.parentNode?.replaceChild(newDom, dom);
return newDom;
}
case 'UPDATE': {
// Apply prop patches
const propPatch = patches.patches.find(p => p.type === 'PROPS');
if (propPatch) {
propPatch.patches.forEach(p => {
if (p.type === 'SET_PROP') {
if (p.key === 'className') {
dom.setAttribute('class', p.value);
} else if (p.key === 'style' && typeof p.value === 'object') {
Object.assign(dom.style, p.value);
} else if (p.key.startsWith('on')) {
// Remove old listener, add new one
const eventType = p.key.slice(2).toLowerCase();
dom.removeEventListener(eventType, dom._listeners?.[p.key]);
dom.addEventListener(eventType, p.value);
dom._listeners = dom._listeners || {};
dom._listeners[p.key] = p.value;
} else {
dom.setAttribute(p.key, p.value);
}
} else if (p.type === 'REMOVE_PROP') {
dom.removeAttribute(p.key);
}
});
}
// Apply child patches
const childPatch = patches.patches.find(p => p.type === 'CHILDREN');
if (childPatch) {
const childNodes = Array.from(dom.childNodes);
childPatch.patches.forEach(({ index, patch }) => {
if (childNodes[index]) {
patch(childNodes[index], patch);
}
});
}
return dom;
}
default:
return dom;
}
}
Step 5: Putting It All Together
A complete working example.
// Complete Virtual DOM implementation
const VDOM = {
h,
render,
diff,
patch
};
// Usage example:
let appRoot = document.getElementById('app');
// Initial render
let count = 0;
function view(count) {
return h('div', { className: 'counter' },
h('h1', null, 'Counter: ' + count),
h('button', {
onClick: () => {
count++;
update(count);
}
}, 'Increment'),
h('button', {
onClick: () => {
count--;
update(count);
}
}, 'Decrement')
);
}
let currentVNode = view(count);
let currentDom = VDOM.render(currentVNode, appRoot);
function update(newCount) {
const newVNode = view(newCount);
const patches = VDOM.diff(currentVNode, newVNode);
currentDom = VDOM.patch(currentDom, patches);
currentVNode = newVNode;
console.log('Updated to:', newCount);
}
// Output:
// Initial: <div class="counter"><h1>Counter: 0</h1>...</div>
// Click increment: only the text node "Counter: 0" changes to "Counter: 1"
// No other DOM operations needed
Common Mistakes
- Not flattening children arrays (render supports nested arrays like lists.map(...)).
- Forgetting to handle event listener cleanup when updating or removing DOM nodes.
- Not handling component functions in createDomElement (calling the function to get its vnode).
- Creating new DOM nodes for every update instead of reusing existing ones.
- Not handling special props like style objects, className, and dangerouslySetInnerHTML.
Practice Questions
- What does the h function do? Creates a virtual node object representing a DOM element or component.
- What is the difference between render and patch? render creates initial DOM from vnodes. patch applies diffs to update existing DOM.
- How does the diff algorithm detect changes? By comparing types, props, and children recursively.
- What happens when two vnodes have different types? The diff produces a REPLACE patch that removes the old DOM node and creates a new one.
Challenge
Extend the simple Virtual DOM to support: keyed list reconciliation (matching children by key), functional components (functions returning vnodes), and SVG elements. Then build a small todo app with add, delete, and toggle functionality using your Virtual DOM.
FAQ
Mini Project
Build a full Virtual DOM library (150-200 lines) with createElement, render, diff, and patch. Then build a small reactive framework on top of it with state management, event handling, and component lifecycle. Use it to create a simple markdown preview editor.
What's Next
Explore the DOM Complete Guide
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro