Skip to content

Building a Simple Virtual DOM — Complete Guide

DodaTech Updated 2026-06-28 8 min read

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

  1. Not flattening children arrays (render supports nested arrays like lists.map(...)).
  2. Forgetting to handle event listener cleanup when updating or removing DOM nodes.
  3. Not handling component functions in createDomElement (calling the function to get its vnode).
  4. Creating new DOM nodes for every update instead of reusing existing ones.
  5. Not handling special props like style objects, className, and dangerouslySetInnerHTML.

Practice Questions

  1. What does the h function do? Creates a virtual node object representing a DOM element or component.
  2. What is the difference between render and patch? render creates initial DOM from vnodes. patch applies diffs to update existing DOM.
  3. How does the diff algorithm detect changes? By comparing types, props, and children recursively.
  4. 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

Can I build a real app with a custom Virtual DOM?

Yes, but you would miss features like batching, event delegation, concurrent rendering, and framework-specific optimizations.

How many lines of code is a minimal Virtual DOM?

A minimal working Virtual DOM with createElement, render, diff, and patch is about 150-200 lines.

Do I need to implement keyed reconciliation?

For lists that can be reordered, yes. Without keys, the positional diff can produce incorrect results.

How does this compare to React's implementation?

This is a simplified educational version. React's Fiber reconciler is several thousand lines with concurrent mode, lanes, and many optimizations.

What is the most important thing I learned from building one?

That the Virtual DOM is not magic — it is a practical data structure and algorithm that anyone can understand and implement.

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