Learning Guides
Menu

Virtual DOM & Reconciliation

12 min readFrontend Patterns & Concepts

Virtual DOM & Reconciliation

The Virtual DOM is one of the most influential innovations in frontend development. Understanding how it works helps you write more performant applications and debug performance issues effectively.

The Problem Virtual DOM Solves

Direct DOM manipulation is slow. Not because of JavaScript, but because of what happens after:

JAVASCRIPT
// Every DOM change can trigger:
// 1. Style recalculation
// 2. Layout (reflow)
// 3. Paint
// 4. Composite
 
element.style.width = "100px"; // Layout + Paint
element.style.height = "200px"; // Layout + Paint again!
element.classList.add("active"); // Style + maybe Layout + Paint
element.textContent = "Hello"; // Layout + Paint
 
// Four separate browser rendering cycles!

The Cost of DOM Operations

JAVASCRIPT
// Measuring DOM operation cost
const element = document.createElement("div");
document.body.appendChild(element);
 
console.time("DOM operations");
for (let i = 0; i < 1000; i++) {
  element.style.width = `${i}px`;
  // Forces synchronous layout
  const width = element.offsetWidth;
}
console.timeEnd("DOM operations");
// Often 100ms+ for forced reflows
 
console.time("Virtual operations");
let virtualWidth = 0;
for (let i = 0; i < 1000; i++) {
  virtualWidth = i;
}
console.timeEnd("Virtual operations");
// Microseconds

What is Virtual DOM?

A Virtual DOM is a lightweight JavaScript representation of the actual DOM. It's just objects:

JAVASCRIPT
// Actual DOM
const actualDOM = document.createElement("div");
actualDOM.className = "container";
actualDOM.appendChild(document.createTextNode("Hello"));
 
// Virtual DOM representation
const virtualDOM = {
  type: "div",
  props: {
    className: "container",
  },
  children: ["Hello"],
};

Building a Simple Virtual DOM

JAVASCRIPT
// Create a virtual element (like React.createElement)
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_NODE",
    props: { nodeValue: text },
    children: [],
  };
}
 
// Usage
const vdom = createElement(
  "div",
  { className: "app" },
  createElement("h1", null, "Hello"),
  createElement("p", { id: "intro" }, "Welcome to Virtual DOM"),
  createElement(
    "ul",
    null,
    createElement("li", null, "Item 1"),
    createElement("li", null, "Item 2"),
  ),
);
 
console.log(JSON.stringify(vdom, null, 2));

Rendering Virtual DOM to Real DOM

JAVASCRIPT
function render(vnode) {
  // Handle text nodes
  if (vnode.type === "TEXT_NODE") {
    return document.createTextNode(vnode.props.nodeValue);
  }
 
  // Create element
  const element = document.createElement(vnode.type);
 
  // Set properties
  Object.entries(vnode.props).forEach(([key, value]) => {
    if (key.startsWith("on")) {
      // Event handlers
      const eventName = key.slice(2).toLowerCase();
      element.addEventListener(eventName, value);
    } else if (key === "className") {
      element.className = value;
    } else if (key === "style" && typeof value === "object") {
      Object.assign(element.style, value);
    } else {
      element.setAttribute(key, value);
    }
  });
 
  // Render children
  vnode.children.forEach((child) => {
    element.appendChild(render(child));
  });
 
  return element;
}
 
// Mount to DOM
const root = document.getElementById("root");
root.appendChild(render(vdom));

The Reconciliation Algorithm (Diffing)

The magic happens when the virtual DOM changes. Instead of re-rendering everything, we diff the old and new virtual trees:

JAVASCRIPT
function diff(oldVNode, newVNode) {
  // Case 1: New node doesn't exist
  if (!newVNode) {
    return { type: "REMOVE" };
  }
 
  // Case 2: Old node doesn't exist
  if (!oldVNode) {
    return { type: "CREATE", newVNode };
  }
 
  // Case 3: Different types
  if (oldVNode.type !== newVNode.type) {
    return { type: "REPLACE", newVNode };
  }
 
  // Case 4: Text node changed
  if (newVNode.type === "TEXT_NODE") {
    if (oldVNode.props.nodeValue !== newVNode.props.nodeValue) {
      return { type: "TEXT", text: newVNode.props.nodeValue };
    }
    return null;
  }
 
  // Case 5: Same type - diff props and children
  return {
    type: "UPDATE",
    props: diffProps(oldVNode.props, newVNode.props),
    children: diffChildren(oldVNode.children, newVNode.children),
  };
}
 
function diffProps(oldProps, newProps) {
  const patches = [];
 
  // Check for removed or changed props
  Object.keys(oldProps).forEach((key) => {
    if (!(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
  Object.keys(newProps).forEach((key) => {
    if (!(key in oldProps)) {
      patches.push({ type: "SET_PROP", key, value: newProps[key] });
    }
  });
 
  return patches;
}
 
function diffChildren(oldChildren, newChildren) {
  const patches = [];
  const maxLength = Math.max(oldChildren.length, newChildren.length);
 
  for (let i = 0; i < maxLength; i++) {
    patches.push(diff(oldChildren[i], newChildren[i]));
  }
 
  return patches;
}

Applying Patches

JAVASCRIPT
function patch(element, patches) {
  if (!patches) return element;
 
  switch (patches.type) {
    case "CREATE":
      return render(patches.newVNode);
 
    case "REMOVE":
      element.remove();
      return null;
 
    case "REPLACE":
      const newElement = render(patches.newVNode);
      element.parentNode.replaceChild(newElement, element);
      return newElement;
 
    case "TEXT":
      element.textContent = patches.text;
      return element;
 
    case "UPDATE":
      // Apply prop patches
      patches.props.forEach((propPatch) => {
        if (propPatch.type === "REMOVE_PROP") {
          element.removeAttribute(propPatch.key);
        } else if (propPatch.type === "SET_PROP") {
          if (propPatch.key.startsWith("on")) {
            // Handle event listener updates (simplified)
            const eventName = propPatch.key.slice(2).toLowerCase();
            element.addEventListener(eventName, propPatch.value);
          } else {
            element.setAttribute(propPatch.key, propPatch.value);
          }
        }
      });
 
      // Patch children
      const childNodes = [...element.childNodes];
      patches.children.forEach((childPatch, i) => {
        patch(childNodes[i], childPatch);
      });
 
      return element;
  }
}

The Key Prop: Why It Matters

Without keys, the reconciler can't efficiently match old and new elements:

JAVASCRIPT
// Old list
const oldList = [
  { type: "li", children: ["A"] },
  { type: "li", children: ["B"] },
  { type: "li", children: ["C"] },
];
 
// New list (B removed)
const newList = [
  { type: "li", children: ["A"] },
  { type: "li", children: ["C"] },
];
 
// Without keys: naive diffing
// Compares index by index:
// - li[0]: 'A' === 'A' ✓ no change
// - li[1]: 'B' !== 'C' → update text to 'C'
// - li[2]: exists vs. undefined → remove
// Result: 1 update + 1 removal = 2 operations
 
// With keys: smart diffing
const oldListKeyed = [
  { type: "li", key: "a", children: ["A"] },
  { type: "li", key: "b", children: ["B"] },
  { type: "li", key: "c", children: ["C"] },
];
 
const newListKeyed = [
  { type: "li", key: "a", children: ["A"] },
  { type: "li", key: "c", children: ["C"] },
];
 
// With keys:
// - key='a': exists in both → no change
// - key='b': only in old → remove
// - key='c': exists in both → no change
// Result: 1 removal = 1 operation

Keyed Reconciliation Algorithm

JAVASCRIPT
function diffChildrenKeyed(oldChildren, newChildren) {
  const oldKeyed = new Map();
  const newKeyed = new Map();
 
  // Index old children by key
  oldChildren.forEach((child, index) => {
    const key = child.props?.key ?? index;
    oldKeyed.set(key, { child, index });
  });
 
  // Index new children by key
  newChildren.forEach((child, index) => {
    const key = child.props?.key ?? index;
    newKeyed.set(key, { child, index });
  });
 
  const patches = [];
 
  // Find removed elements
  oldKeyed.forEach((value, key) => {
    if (!newKeyed.has(key)) {
      patches.push({
        type: "REMOVE",
        index: value.index,
      });
    }
  });
 
  // Find added or moved elements
  let lastIndex = 0;
  newChildren.forEach((newChild, newIndex) => {
    const key = newChild.props?.key ?? newIndex;
    const oldEntry = oldKeyed.get(key);
 
    if (!oldEntry) {
      // New element
      patches.push({
        type: "INSERT",
        index: newIndex,
        vnode: newChild,
      });
    } else {
      // Existing element - check if needs move
      if (oldEntry.index < lastIndex) {
        patches.push({
          type: "MOVE",
          from: oldEntry.index,
          to: newIndex,
        });
      }
 
      // Diff the element itself
      const childPatch = diff(oldEntry.child, newChild);
      if (childPatch) {
        patches.push({
          type: "PATCH",
          index: newIndex,
          patches: childPatch,
        });
      }
 
      lastIndex = Math.max(lastIndex, oldEntry.index);
    }
  });
 
  return patches;
}

Batching Updates

React and other frameworks batch multiple setState calls:

JAVASCRIPT
class BatchedUpdater {
  constructor(component) {
    this.component = component;
    this.pendingState = null;
    this.isUpdateScheduled = false;
  }
 
  setState(partialState) {
    // Merge with pending state
    this.pendingState = {
      ...this.pendingState,
      ...partialState,
    };
 
    // Schedule single update
    if (!this.isUpdateScheduled) {
      this.isUpdateScheduled = true;
 
      // Use microtask for batching
      queueMicrotask(() => {
        this.flush();
      });
    }
  }
 
  flush() {
    if (this.pendingState) {
      const newState = {
        ...this.component.state,
        ...this.pendingState,
      };
 
      this.component.state = newState;
      this.pendingState = null;
      this.isUpdateScheduled = false;
 
      // Single re-render with all updates
      this.component.render();
    }
  }
}
 
// Without batching:
setState({ a: 1 }); // render
setState({ b: 2 }); // render
setState({ c: 3 }); // render = 3 renders!
 
// With batching:
setState({ a: 1 }); // queue
setState({ b: 2 }); // queue
setState({ c: 3 }); // queue
// microtask: single render with {a:1, b:2, c:3}

React's Fiber Architecture

React 16+ uses Fiber for interruptible rendering:

JAVASCRIPT
// Simplified Fiber node
interface Fiber {
  type: string | Function;
  props: object;
 
  // Tree structure
  parent: Fiber | null;
  child: Fiber | null;
  sibling: Fiber | null;
 
  // Alternate for double-buffering
  alternate: Fiber | null;
 
  // Effects
  effectTag: 'PLACEMENT' | 'UPDATE' | 'DELETION';
 
  // Work tracking
  expirationTime: number;
}

Work Loop with Time Slicing

JAVASCRIPT
let nextUnitOfWork = null;
let currentRoot = null;
let wipRoot = null;
let deletions = [];
 
function workLoop(deadline) {
  let shouldYield = false;
 
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
 
    // Check if we should yield to browser
    shouldYield = deadline.timeRemaining() < 1;
  }
 
  // Commit phase if all work done
  if (!nextUnitOfWork && wipRoot) {
    commitRoot();
  }
 
  // Schedule next work
  requestIdleCallback(workLoop);
}
 
requestIdleCallback(workLoop);
 
function performUnitOfWork(fiber) {
  // Perform work on this fiber
  if (fiber.type instanceof Function) {
    updateFunctionComponent(fiber);
  } else {
    updateHostComponent(fiber);
  }
 
  // Return next unit of work
  // Depth-first traversal: child → sibling → uncle
  if (fiber.child) {
    return fiber.child;
  }
 
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
 
  return null;
}

Optimizing Reconciliation

shouldComponentUpdate / React.memo

JAVASCRIPT
// Class component
class ExpensiveComponent extends React.Component {
  shouldComponentUpdate(nextProps, nextState) {
    // Only re-render if relevant props changed
    return (
      nextProps.data !== this.props.data ||
      nextProps.selectedId !== this.props.selectedId
    );
  }
 
  render() {
    // Expensive render
  }
}
 
// Function component
const ExpensiveFunction = React.memo(
  function ({ data, selectedId }) {
    // Expensive render
  },
  (prevProps, nextProps) => {
    // Return true to skip re-render
    return (
      prevProps.data === nextProps.data &&
      prevProps.selectedId === nextProps.selectedId
    );
  },
);

Immutable Updates

JAVASCRIPT
// ❌ Mutating state defeats reconciliation
state.items.push(newItem);
setState({ items: state.items }); // Same reference!
 
// ✅ Immutable update creates new reference
setState({
  items: [...state.items, newItem],
});
 
// For nested updates
setState({
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      name: "New Name",
    },
  },
});
 
// Or use immer
import produce from "immer";
 
setState(
  produce((draft) => {
    draft.user.profile.name = "New Name";
  }),
);

Lifting State Up

JAVASCRIPT
// ❌ Child manages list state - parent can't optimize
function Parent() {
  return <Child />;
}
 
function Child() {
  const [items, setItems] = useState([]);
  return items.map((item) => <Item key={item.id} item={item} />);
}
 
// ✅ Parent manages, passes minimal props
function Parent() {
  const [items, setItems] = useState([]);
  const [selectedId, setSelectedId] = useState(null);
 
  return (
    <List>
      {items.map((item) => (
        <MemoizedItem
          key={item.id}
          item={item}
          isSelected={item.id === selectedId}
          onSelect={setSelectedId}
        />
      ))}
    </List>
  );
}
 
const MemoizedItem = React.memo(function Item({ item, isSelected, onSelect }) {
  // Only re-renders when item or isSelected changes
  return (
    <div
      className={isSelected ? "selected" : ""}
      onClick={() => onSelect(item.id)}
    >
      {item.name}
    </div>
  );
});

Keying Strategies

JAVASCRIPT
// ❌ Using index as key
{
  items.map((item, index) => <Item key={index} item={item} />);
}
// Problems: reordering, inserting, deleting
 
// ✅ Using stable unique ID
{
  items.map((item) => <Item key={item.id} item={item} />);
}
 
// ❌ Generating key during render
{
  items.map((item) => <Item key={Math.random()} item={item} />);
}
// Forces complete re-mount every render!
 
// ✅ For composite keys
{
  items.map((item) => (
    <Item key={`${item.categoryId}-${item.id}`} item={item} />
  ));
}

Profiling Reconciliation

React DevTools Profiler

JAVASCRIPT
// Wrap with Profiler component
import { Profiler } from "react";
 
function onRenderCallback(
  id, // the "id" prop of the Profiler tree that has just committed
  phase, // either "mount" or "update"
  actualDuration, // time spent rendering the committed update
  baseDuration, // estimated time to render the entire subtree without memoization
  startTime, // when React began rendering this update
  commitTime, // when React committed this update
  interactions, // the Set of interactions belonging to this update
) {
  console.log({
    id,
    phase,
    actualDuration,
    baseDuration,
  });
}
 
<Profiler id="App" onRender={onRenderCallback}>
  <App />
</Profiler>;

Why Did You Render

JAVASCRIPT
// Development-only tracking
if (process.env.NODE_ENV === "development") {
  const whyDidYouRender = require("@welldone-software/why-did-you-render");
  whyDidYouRender(React, {
    trackAllPureComponents: true,
  });
}
 
// Mark specific components
MyComponent.whyDidYouRender = true;

Building a Minimal React Clone

JAVASCRIPT
let wipFiber = null;
let hookIndex = null;
 
function useState(initial) {
  const oldHook = wipFiber.alternate?.hooks?.[hookIndex];
 
  const hook = {
    state: oldHook ? oldHook.state : initial,
    queue: [],
  };
 
  // Process queued actions from previous render
  const actions = oldHook ? oldHook.queue : [];
  actions.forEach((action) => {
    hook.state = typeof action === "function" ? action(hook.state) : action;
  });
 
  const setState = (action) => {
    hook.queue.push(action);
 
    // Trigger re-render
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    };
    nextUnitOfWork = wipRoot;
    deletions = [];
  };
 
  wipFiber.hooks.push(hook);
  hookIndex++;
 
  return [hook.state, setState];
}
 
function updateFunctionComponent(fiber) {
  wipFiber = fiber;
  hookIndex = 0;
  wipFiber.hooks = [];
 
  const children = [fiber.type(fiber.props)];
  reconcileChildren(fiber, children);
}
 
function reconcileChildren(wipFiber, elements) {
  let index = 0;
  let oldFiber = wipFiber.alternate?.child;
  let prevSibling = null;
 
  while (index < elements.length || oldFiber) {
    const element = elements[index];
    let newFiber = null;
 
    const sameType = oldFiber && element && element.type === oldFiber.type;
 
    if (sameType) {
      // Update
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      };
    }
 
    if (element && !sameType) {
      // Add
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      };
    }
 
    if (oldFiber && !sameType) {
      // Delete
      oldFiber.effectTag = "DELETION";
      deletions.push(oldFiber);
    }
 
    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }
 
    if (index === 0) {
      wipFiber.child = newFiber;
    } else if (element) {
      prevSibling.sibling = newFiber;
    }
 
    prevSibling = newFiber;
    index++;
  }
}

Summary

ConceptDescriptionBenefit
Virtual DOMJS representation of DOMCheap comparisons
ReconciliationDiff algorithmMinimal DOM updates
KeysStable identifiersEfficient list updates
BatchingGrouped state updatesSingle re-render
FiberInterruptible renderingResponsive UI
MemoizationSkip unchanged subtreesFaster renders

Key Takeaways:

  1. Virtual DOM isn't about being faster than DOM—it's about minimizing actual DOM operations
  2. Good keys are essential for list performance
  3. Immutable updates enable cheap reference equality checks
  4. Profile before optimizing—premature memoization adds complexity
  5. Understanding reconciliation helps you write naturally performant code