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:
- Render - Components return VNodes describing what the UI should look like
- Diff - Preact compares the new VNode tree with the previous one
- 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.// Efficient - Preact tracks items by key
todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))
With keys, Preact can reuse existing DOM nodes and only update what 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:
- Applies refs to DOM nodes
- Invokes lifecycle callbacks (like
componentDidMount)
- Handles errors gracefully
The commit phase is synchronous and cannot be interrupted, ensuring the DOM is always in a consistent state.