Skip to main content
The Virtual DOM (VDOM) is a lightweight JavaScript representation of the actual DOM. Preact uses it to efficiently determine what changes need to be made to the real DOM.

What is a VNode?

A VNode (Virtual Node) is Preact’s internal representation of a DOM element or component. Here’s how VNodes are created (src/create-element.js:47):
export function createVNode(type, props, key, ref, original) {
  const vnode = {
    type,
    props,
    key,
    ref,
    _children: NULL,
    _parent: NULL,
    _depth: 0,
    _dom: NULL,
    _component: NULL,
    constructor: UNDEFINED,
    _original: original == NULL ? ++vnodeId : original,
    _index: -1,
    _flags: 0
  };

  if (original == NULL && options.vnode != NULL) options.vnode(vnode);

  return vnode;
}

VNode structure

Each VNode contains:
  • type - The element type (string for DOM elements, function for components)
  • props - The element’s properties and attributes
  • key - A unique identifier for efficient list diffing
  • ref - A reference to the actual DOM node or component instance
  • _children - Child VNodes
  • _dom - The corresponding real DOM node
  • _component - The component instance (for component VNodes)
  • _parent - The parent VNode
  • _depth - Depth in the VNode tree
Properties prefixed with _ are internal to Preact and should not be accessed directly.

How the Virtual DOM works

Preact’s Virtual DOM operates in three phases:
  1. Render - Components return VNodes describing what the UI should look like
  2. Diff - Preact compares the new VNode tree with the previous one
  3. Commit - Only the differences are applied to the real DOM

The diff algorithm

The diff algorithm is the heart of Preact’s Virtual DOM. It’s implemented in src/diff/index.js:58:
export function diff(
  parentDom,
  newVNode,
  oldVNode,
  globalContext,
  namespace,
  excessDomChildren,
  commitQueue,
  oldDom,
  isHydrating,
  refQueue,
  doc
) {
  let tmp,
    newType = newVNode.type;

  // When passing through createElement it assigns the object
  // constructor as undefined. This to prevent JSON-injection.
  if (newVNode.constructor !== UNDEFINED) return NULL;

  if ((tmp = options._diff)) tmp(newVNode);

  // ... diffing logic
}

Diffing components

When diffing components, Preact determines whether to reuse an existing instance or create a new one (src/diff/index.js:100):
const isClassComponent =
  'prototype' in newType && newType.prototype.render;

// Get component and set it to `c`
if (oldVNode._component) {
  c = newVNode._component = oldVNode._component;
  if (c._bits & COMPONENT_PENDING_ERROR) {
    c._bits |= COMPONENT_PROCESSING_EXCEPTION;
  }
} else {
  // Instantiate the new component
  if (isClassComponent) {
    newVNode._component = c = new newType(newProps, componentContext);
  } else {
    newVNode._component = c = new BaseComponent(
      newProps,
      componentContext
    );
    c.constructor = newType;
    c.render = doRender;
  }
  // ...
}
Preact differentiates between class and functional components by checking if prototype.render exists.

Diffing children

Child diffing is optimized using keys and a skew-based algorithm (src/diff/children.js:45):
export function diffChildren(
  parentDom,
  renderResult,
  newParentVNode,
  oldParentVNode,
  globalContext,
  namespace,
  excessDomChildren,
  commitQueue,
  oldDom,
  isHydrating,
  refQueue,
  doc
) {
  let oldChildren = (oldParentVNode && oldParentVNode._children) || EMPTY_ARR;
  let newChildrenLength = renderResult.length;

  oldDom = constructNewChildrenArray(
    newParentVNode,
    renderResult,
    oldChildren,
    oldDom,
    newChildrenLength
  );

  for (i = 0; i < newChildrenLength; i++) {
    childVNode = newParentVNode._children[i];
    if (childVNode == NULL) continue;

    // Morph the old element into the new one
    diff(
      parentDom,
      childVNode,
      oldVNode,
      globalContext,
      namespace,
      excessDomChildren,
      commitQueue,
      oldDom,
      isHydrating,
      refQueue,
      doc
    );
  }
}

Key-based reconciliation

Keys help Preact identify which items have changed, been added, or removed in lists:
// Inefficient - Preact can't identify items
todos.map(todo => (
  <li>{todo.text}</li>
))
Without keys, Preact may re-render all items even if only one changed.
Never use array indices as keys if the list can be reordered. This can cause incorrect updates and poor performance.

Finding matching VNodes

Preact’s diff algorithm uses a sophisticated matching strategy (src/diff/children.js:400):
function findMatchingIndex(
  childVNode,
  oldChildren,
  skewedIndex,
  remainingOldChildren
) {
  const key = childVNode.key;
  const type = childVNode.type;
  let oldVNode = oldChildren[skewedIndex];
  const matched = oldVNode != NULL && (oldVNode._flags & MATCHED) == 0;

  // Check if types and keys match
  if (
    (oldVNode === NULL && key == null) ||
    (matched && key == oldVNode.key && type == oldVNode.type)
  ) {
    return skewedIndex;
  } else if (shouldSearch) {
    // Search nearby positions
    let x = skewedIndex - 1;
    let y = skewedIndex + 1;
    while (x >= 0 || y < oldChildren.length) {
      const childIndex = x >= 0 ? x-- : y++;
      oldVNode = oldChildren[childIndex];
      if (
        oldVNode != NULL &&
        (oldVNode._flags & MATCHED) == 0 &&
        key == oldVNode.key &&
        type == oldVNode.type
      ) {
        return childIndex;
      }
    }
  }

  return -1;
}
This algorithm optimizes for common cases like insertions, deletions, and reorderings.

Efficient updates

Preact’s Virtual DOM provides several performance benefits:

Batched updates

Multiple state changes are batched together into a single DOM update:
// These three setState calls result in one DOM update
this.setState({ count: 1 });
this.setState({ name: 'Alice' });
this.setState({ active: true });

Minimal DOM manipulation

Only the parts of the DOM that actually changed are updated:
<div className="container">
  <h1>Count: 5</h1>
  <p>Last updated: 10:30 AM</p>
</div>

Smart component updates

Components can skip unnecessary renders using shouldComponentUpdate():
import { Component } from 'preact';

class Child extends Component {
  shouldComponentUpdate(nextProps, nextState) {
    // Only re-render if the id changed
    return this.props.id !== nextProps.id;
  }

  render() {
    return <p>Child {this.props.id}</p>;
  }
}

Committing changes

After diffing, Preact commits the changes to the real DOM (src/diff/index.js:406):
export function commitRoot(commitQueue, root, refQueue) {
  for (let i = 0; i < refQueue.length; i++) {
    applyRef(refQueue[i], refQueue[++i], refQueue[++i]);
  }

  if (options._commit) options._commit(root, commitQueue);

  commitQueue.some(c => {
    try {
      commitQueue = c._renderCallbacks;
      c._renderCallbacks = [];
      commitQueue.some(cb => {
        cb.call(c);
      });
    } catch (e) {
      options._catchError(e, c._vnode);
    }
  });
}
This phase:
  1. Applies refs to DOM nodes
  2. Invokes lifecycle callbacks (like componentDidMount)
  3. Handles errors gracefully
The commit phase is synchronous and cannot be interrupted, ensuring the DOM is always in a consistent state.

Build docs developers (and LLMs) love