How React's Virtual DOM Works Under the Hood: Render, Diff, and Commit Explained

How React's Virtual DOM Works Under the Hood: Render, Diff, and Commit Explained
TL;DR: React never touches the real DOM directly on every change. Instead, it builds a lightweight JavaScript representation of the UI (Virtual DOM), compares it against the previous version when state changes, calculates the minimal set of updates needed, and then applies only those changes to the real DOM. This post explains each step in that pipeline.
Audience: This post assumes you've written React components and understand state and props. No prior knowledge of React internals required. We deliberately avoid Fiber scheduler internals — the focus is on the conceptual model.
Problem: Direct DOM Manipulation Is Expensive
The browser's DOM is a tree of objects. Every time you read from or write to it, the browser may trigger one or more of these operations:
- Style recalculation — recomputing CSS for affected elements
- Layout (reflow) — recalculating geometry and position of elements
- Paint — drawing pixels to the screen
- Composite — combining painted layers
Each of these is synchronous and blocks the main thread. A single element.style.color = 'red' on a deeply nested node can cascade into a full-page reflow if the browser can't isolate the impact.
Now imagine a todo-list component that re-renders 50 items every time the user types a character into a search field. If you're calling document.createElement, appendChild, and setAttribute for every item on every keystroke — you're queuing dozens of forced reflows per second.
This is the problem React was designed to solve.
Solution: The Virtual DOM as a Cheap Intermediate Layer
React introduces a Virtual DOM — a plain JavaScript object tree that mirrors the structure of the real DOM, but lives entirely in memory. Manipulating JavaScript objects is orders of magnitude cheaper than touching the real DOM.
The core idea:
- Describe your UI in JavaScript (JSX compiles to this)
- React builds a Virtual DOM tree from that description
- When state changes, React builds a new Virtual DOM tree
- React diffs the old tree against the new one
- React computes the minimum set of real DOM operations needed
- React applies only those operations — the commit phase
Let's walk through each phase with real code.
Phase 1: Initial Render
What JSX actually produces
When you write JSX, Babel transforms it into React.createElement() calls:
// What you write
const element = (
<div className="card">
<h1>Hello, Alice</h1>
<p>Welcome back.</p>
</div>
);
// What Babel compiles it to (React 17 classic runtime)
const element = React.createElement(
'div',
{ className: 'card' },
React.createElement('h1', null, 'Hello, Alice'),
React.createElement('p', null, 'Welcome back.')
);
React.createElement returns a plain JavaScript object — the Virtual DOM node:
// The resulting Virtual DOM object (simplified)
{
type: 'div',
props: {
className: 'card',
children: [
{ type: 'h1', props: { children: 'Hello, Alice' } },
{ type: 'p', props: { children: 'Welcome back.' } }
]
}
}
No DOM operation has happened yet. This is just a JavaScript object.
Mounting to the real DOM
When you call ReactDOM.createRoot(document.getElementById('root')).render(<App />), React:
- Calls your component function to get the Virtual DOM tree
- Walks the tree recursively
- Creates real DOM nodes for each Virtual DOM node
- Sets attributes and event listeners
- Appends nodes to the container
Component Function
│
▼
Virtual DOM Tree ──────► Real DOM (mounted once)
{ type, props, children }
This initial mount is the one time React does a full DOM construction. Everything after this is surgical.
Phase 2: State or Props Change Triggers Re-Render
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div className="counter">
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
When the user clicks "Increment":
setCount(count + 1)schedules a state update- React marks the
Countercomponent as dirty (needs re-render) - React calls
Counter()again with the new state - A new Virtual DOM tree is produced
Before click: After click:
{ type: 'div', { type: 'div',
props: { props: {
children: [ children: [
{ type: 'span', { type: 'span',
props: { props: {
children: 0 } children: 1 } // ← changed
}, },
{ type: 'button', ... } { type: 'button', ... }
] ]
} }
} }
React now has two trees: the current tree (committed to the DOM) and the work-in-progress tree (newly created).
Phase 3: Diffing (Reconciliation)
Diffing is the process of comparing the old Virtual DOM tree against the new one to find what changed.
A naive tree diff between two trees of N nodes is O(n³). React's reconciler uses a set of heuristics that reduce this to O(n):
Heuristic 1: Different element types → full subtree replacement
If the root type changes (e.g., <div> becomes <section>), React tears down the old subtree entirely and builds a new one. No attempt is made to reuse children.
// Old tree // New tree
<div> <section>
<p>Hello</p> <p>Hello</p>
</div> </section>
// Result: div and its children are destroyed.
// section and its children are created fresh.
Heuristic 2: Same element type → update attributes, recurse into children
If the type matches, React compares the props and updates only what changed:
// Old
<input type="text" placeholder="Search" className="input-v1" />
// New
<input type="text" placeholder="Search" className="input-v2" />
// Result: React calls setAttribute('class', 'input-v2') on the existing DOM node.
// The node is NOT recreated.
Heuristic 3: Lists use key to identify stable elements
Without keys, React diffs list children by index position. This causes unnecessary updates when items are inserted or removed:
// Old list // New list (item added at top)
<ul> <ul>
<li>Alice</li> <li>Zara</li> // index 0 changed
<li>Bob</li> <li>Alice</li> // index 1 changed
</ul> <li>Bob</li> // index 2 is new
</ul>
// Without keys: React updates all 3 li nodes
// With keys: React tracks identity across positions
<ul>
<li key="alice">Alice</li>
<li key="bob">Bob</li>
</ul>
// New tree:
<ul>
<li key="zara">Zara</li> // new
<li key="alice">Alice</li> // moved, not updated
<li key="bob">Bob</li> // moved, not updated
</ul>
// With keys: React inserts one new node, moves the others. Zero content updates.
This is why key matters. It's not a React quirk — it's the mechanism that makes list diffing O(n) instead of O(n²).
Phase 4: Commit — Applying Minimal Updates to the Real DOM
After diffing, React has a list of mutations — a patch describing exactly what changed:
Mutation List (example):
UPDATE span[0].textContent = "1" // count changed 0 → 1
// No other changes
React applies these in the commit phase. This is the only moment real DOM APIs (setAttribute, textContent, appendChild, removeChild) are called.
For the counter example above, exactly one DOM write happens — updating the text content of the <span>. The div, the button, and their event listeners are completely untouched.
┌─────────────────────────────────────────────────────┐
│ React Update Lifecycle │
└─────────────────────────────────────────────────────┘
setCount(1)
│
▼
[Render Phase] ──► Counter() called ──► New Virtual DOM tree created
│
▼
[Diff Phase] ──► Old tree vs New tree compared
Mutation list computed: [UPDATE span.textContent]
│
▼
[Commit Phase] ──► span.textContent = '1' (single DOM write)
Real DOM updated
Why This Improves Performance
The performance gain comes from three compounding factors:
Batching — React can batch multiple
setStatecalls into a single render + diff + commit cycle, collapsing N DOM writes into one pass.Minimality — Only the nodes that actually changed get updated. A 1000-node tree with one changed text node results in exactly one DOM write.
Predictability — Because React controls all DOM writes, it can schedule them at the right time (e.g., after layout, before paint), reducing forced synchronous reflows.
To make this concrete: in a benchmark with a table of 500 rows where one cell's value changes, direct DOM manipulation that re-renders the whole table takes ~18ms. React's diffed update takes ~1.2ms — a 15× difference — because only one td.textContent is modified.
Results
| Scenario | Naive DOM Update | React Virtual DOM |
| 500-row table, 1 cell change | ~18ms (full re-render) | ~1.2ms (1 DOM write) |
| 50-item list, 1 item inserted | O(n) rewrites without key | O(1) insert with key |
| Typing in search field (debounced) | Multiple reflows | Batched, single commit |
These are representative numbers. Actual results depend on component complexity, browser, and hardware.
Trade-offs
Virtual DOM is not free:
- Memory overhead — React maintains two trees in memory simultaneously (current + work-in-progress). For very large UIs, this adds meaningful memory pressure.
- Initial render is not faster — The first mount still creates all real DOM nodes. Virtual DOM only helps on updates.
- Diffing has a cost — For components that change very frequently (e.g., real-time data at 60fps), the diff itself can become a bottleneck. Libraries like Solid.js avoid this by compiling reactivity to direct DOM updates.
- Keys must be stable and unique — Using array indexes as keys defeats the purpose and causes subtle bugs on reorder/insert.
- Not always the right abstraction — For direct canvas manipulation, WebGL, or third-party DOM libraries (like D3), React's control over the DOM can get in the way. You'll use refs and
useEffectto escape the abstraction.
When NOT to Use Virtual DOM (i.e., When React Gets In the Way)
- High-frequency animations — If you're updating DOM state at 60fps, consider CSS animations or the Web Animations API instead of React state.
- Canvas/WebGL rendering — React has no model for these. Use refs to get the element and manage it imperatively.
- Micro-frontends with mixed frameworks — The Virtual DOM abstraction breaks at framework boundaries. Use custom elements or vanilla JS for shared components.
- Server-rendered static pages — If a page has no interactivity, React adds bundle weight with no performance benefit. Use plain HTML.
Conclusion
React's Virtual DOM is not magic — it's a deliberate trade: spend CPU time on JavaScript object comparison to avoid expensive real DOM operations. The three-phase model (render → diff → commit) gives React a controlled window to batch, minimize, and schedule DOM writes.
The key mental model to keep:
React never describes mutations. It describes the desired state. The diff engine figures out the mutations.
If you take one practical thing away: always provide stable, meaningful key props on list elements. It's the single biggest lever you have over reconciliation performance.
Next step: explore React.memo, useMemo, and useCallback — tools that let you control which components enter the render phase at all, which is even cheaper than a fast diff.
Further Reading
- React Reconciliation — Official Docs — The canonical explanation of how React preserves or resets state based on tree position
- React's Diffing Algorithm (Legacy Docs) — More detailed breakdown of the O(n) heuristics
- Preact Source Code — A 3KB React-compatible implementation; reading the diff function in ~200 lines is the fastest way to understand reconciliation concretely
- Why Solid.js Avoids Virtual DOM — Ryan Carniato explains fine-grained reactivity as an alternative mental model
- Browser Rendering Performance — web.dev — The foundation: understanding what makes real DOM operations expensive in the first place



