How to Create Your Own Virtual DOM from Scratch

Team 6 min read

#webdev

#javascript

#virtual-dom

#tutorial

Overview

Creating a virtual DOM (VDOM) from scratch can demystify how modern frameworks manage updates efficiently. In this guide, you’ll learn the core ideas, implement a tiny VDOM layer in plain JavaScript, and see how a simple app can render and update with minimal DOM mutations.

What is a Virtual DOM?

A Virtual DOM is a lightweight JavaScript representation of the real DOM. It lets you describe UI changes as a tree of plain objects (VNode), computes the difference (diff) between the old and new trees, and applies only the necessary updates to the actual DOM (patch). This approach minimizes expensive DOM operations and can improve performance for interactive apps.

Core ideas

  • Represent UI as VNodes: { type, props, children }.
  • Create actual DOM nodes from VNodes (render).
  • Compare two VNodes (diff) and patch only what changed (update).
  • Support props like attributes, styles, and event listeners.
  • Keep a lightweight API surface: h(), createElement(), patch(), render().

Minimal API

  • h(type, props, …children): create a VNode.
  • createElement(vnode): convert a VNode to a real DOM node.
  • patch(parent, oldVnode, newVnode, index): update the DOM to reflect changes from oldVnode to newVnode.
  • render(vnode, container): mount or re-render into a container.

Implementation: a tiny virtual DOM

Below is a compact, working JavaScript implementation you can drop into a script tag. It includes a small demo app to show how updates propagate with minimal DOM touches.

<!doctype html>
<html>
  <body>
    <div id="root"></div>

    <script>
      // Tiny VDOM API

      // 1) VNode factory
      function h(type, props, ...children) {
        const flat = children.flat ? children.flat() : children;
        const normalized = flat.filter(c => c != null && c !== false && c !== true);
        return { type, props: props || {}, children: normalized };
      }

      // 2) Create real DOM from VNode
      function createElement(vnode) {
        if (typeof vnode === 'string' || typeof vnode === 'number') {
          return document.createTextNode(vnode);
        }

        const el = document.createElement(vnode.type);
        const { props } = vnode;

        if (props) {
          for (const [k, v] of Object.entries(props)) {
            if (k === 'style' && typeof v === 'object') {
              Object.assign(el.style, v);
            } else if (k.startsWith('on') && typeof v === 'function') {
              const eventName = k.substring(2).toLowerCase();
              el.addEventListener(eventName, v);
            } else {
              el.setAttribute(k, v);
            }
          }
        }

        (vnode.children || []).forEach(child => {
          el.appendChild(createElement(child));
        });

        return el;
      }

      // 3) Diff & patch
      function updateProps(dom, oldProps = {}, newProps = {}) {
        // Set or update props
        for (const [k, v] of Object.entries(newProps)) {
          if (oldProps[k] !== v) {
            if (k === 'style' && typeof v === 'object') {
              Object.assign(dom.style, v);
            } else if (k.startsWith('on') && typeof v === 'function') {
              const eventName = k.substring(2).toLowerCase();
              if (typeof oldProps[k] === 'function') dom.removeEventListener(eventName, oldProps[k]);
              dom.addEventListener(eventName, v);
            } else {
              dom.setAttribute(k, v);
            }
          }
        }
        // Remove old props not present anymore
        for (const k of Object.keys(oldProps)) {
          if (!(k in newProps)) {
            dom.removeAttribute(k);
          }
        }
      }

      function patch(parent, oldVnode, newVnode, index = 0) {
        const existingDom = parent.childNodes[index];

        if (!oldVnode) {
          parent.appendChild(createElement(newVnode));
          return;
        }

        if (!newVnode) {
          if (existingDom) parent.removeChild(existingDom);
          return;
        }

        // Text/numeric nodes
        if (
          typeof oldVnode === 'string' ||
          typeof oldVnode === 'number' ||
          typeof newVnode === 'string' ||
          typeof newVnode === 'number'
        ) {
          if (oldVnode !== newVnode) {
            parent.replaceChild(createElement(newVnode), existingDom);
          }
          return;
        }

        // Different types -> replace
        if (oldVnode.type !== newVnode.type) {
          parent.replaceChild(createElement(newVnode), existingDom);
          return;
        }

        // Same type -> update props and patch children
        updateProps(existingDom, oldVnode.props, newVnode.props);

        const oldChildren = oldVnode.children || [];
        const newChildren = newVnode.children || [];
        const max = Math.max(oldChildren.length, newChildren.length);

        for (let i = 0; i < max; i++) {
          patch(existingDom, oldChildren[i], newChildren[i], i);
        }
      }

      // 4) Rendering with a tiny diff-based engine
      let mountedVNode = null;

      function render(vnode, container) {
        if (!container.__vroot) {
          // initial mount
          container.innerHTML = '';
          container.appendChild(createElement(vnode));
          container.__vroot = vnode;
        } else {
          // patch against previous
          patch(container, container.__vroot, vnode, 0);
          container.__vroot = vnode;
        }
      }

      // Demo app: a simple counter with a toggle
      const state = { count: 0, show: true };

      function view() {
        return h('div', { id: 'app' },
          h('h2', null, 'Virtual DOM from Scratch'),
          h('button', { onclick: () => { state.count++; render(view(), document.getElementById('root')); }, style: { marginRight: '8px' }}, 'Increment'),
          h('span', null, ` Count: ${state.count}`),
          h('button', { onclick: () => { state.show = !state.show; render(view(), document.getElementById('root')); } }, state.show ? 'Hide' : 'Show'),
          state.show ? h('p', null, 'This paragraph is conditionally rendered.') : null
        );
      }

      // Bootstrap
      window.onload = () => {
        render(view(), document.getElementById('root'));
      };
    </script>
  </body>
</html>

A small working example in vanilla JS

  • This minimal example provides:
    • A VNode representation with h(type, props, …children).
    • A DOM builder via createElement.
    • A simple patch algorithm to diff and apply updates.
    • A tiny interactive app (counter + show/hide) to demonstrate re-rendering with minimal DOM ops.

Rendering strategy and performance notes

  • The diff algorithm is intentionally small but captures the essential idea: compare old and new trees, update props, then recurse into children.
  • For lists, consider adding stable keys to preserve items and avoid re-mounts. Without keys, reordering can cause unnecessary DOM churn.
  • Use event delegation where possible to keep per-element listeners minimal, or ensure you clean up listeners when patching.
  • For large trees, you may want to implement a more sophisticated diffing strategy (e.g., keyed children, subtree reuse) to maximize performance.

How to extend this

  • Add keys to VNodes: { type, key, props, children } and adjust patch to match by key for list reconciliation.
  • Support functional components: h(Comp, props, …children) where Comp returns another VNode.
  • Implement fragments to render siblings without wrapping in an extra element.
  • Improve edge cases: boolean props, dataset attributes, className handling, and more robust style merging.
  • Integrate into a small framework or library bootstrap to manage higher-level state and components.

Testing and caveats

  • Test with simple and complex trees to ensure patches do not regress expected UI.
  • Be mindful of event listener cleanup to avoid memory leaks in long-running apps.
  • Remember that real-world frameworks optimize many edge cases; this micro-VDOM is for learning and experimentation.

Conclusion

Building a tiny Virtual DOM from scratch helps you understand the core trade-offs behind modern UI libraries: declarative rendering, diff-based updates, and minimal DOM mutations. With the lightweight API and the included example, you can experiment, extend, and gain intuition about how complex frameworks manage updates under the hood.