FossilsšŸ’» CodingImplement a Virtual DOM
šŸ¦–DinosaurVirtual DOMReactImplementation

Implement a Virtual DOM

Building a simplified Virtual DOM reveals your understanding of React's core abstraction — the reconciliation between a tree of objects and the real DOM.

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"> becomes createElement('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);
  }
}

"render recursively 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 requestIdleCallback semantics, 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