Virtual DOM & Reconciliation
12 min read•Frontend 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");
// MicrosecondsWhat 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 operationKeyed 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
| Concept | Description | Benefit |
|---|---|---|
| Virtual DOM | JS representation of DOM | Cheap comparisons |
| Reconciliation | Diff algorithm | Minimal DOM updates |
| Keys | Stable identifiers | Efficient list updates |
| Batching | Grouped state updates | Single re-render |
| Fiber | Interruptible rendering | Responsive UI |
| Memoization | Skip unchanged subtrees | Faster renders |
Key Takeaways:
- Virtual DOM isn't about being faster than DOM—it's about minimizing actual DOM operations
- Good keys are essential for list performance
- Immutable updates enable cheap reference equality checks
- Profile before optimizing—premature memoization adds complexity
- Understanding reconciliation helps you write naturally performant code