How React Works (Virtual DOM)
To use React effectively — and to debug it confidently — you need a mental model of what happens between calling setState and seeing a pixel change on screen. This page builds that mental model from the ground up: the browser DOM, the Virtual DOM, reconciliation, React Fiber, and the two-phase rendering cycle.
The Browser DOM and Why Direct Updates Are Slow
The Document Object Model (DOM) is the browser's live, tree-structured representation of your HTML. Every element is a node. JavaScript can read and modify these nodes via APIs like document.querySelector and element.innerHTML.
The problem is that DOM operations are expensive. When you change a DOM node, the browser may need to:
Recalculate styles — figure out which CSS rules now apply
Reflow (layout) — measure and position every affected element
Repaint — redraw pixels to the screen
Composite — merge layers and push to the GPU
Not every change triggers all four steps, but complex pages with deeply nested elements can cause cascading reflows that block the main thread and make the UI feel sluggish. The real performance problem is not one DOM update — it is dozens of small, unsynchronized updates that each trigger a separate reflow cycle.
What Is the Virtual DOM?
React's solution is the Virtual DOM (VDOM): a plain JavaScript object tree that represents what the UI should look like. Virtual DOM nodes are cheap to create — they are just JavaScript objects with no layout, painting, or compositing cost.
When you write JSX, Babel transforms it into React.createElement calls that produce these plain objects:
// What you write:
const element = <h1 className="title">Hello</h1>
// What Babel transforms it into:
const element = React.createElement(
'h1',
{ className: 'title' },
'Hello'
)
// What that produces (a plain JS object):
{
type: 'h1',
props: {
className: 'title',
children: 'Hello'
}
}This VDOM object costs almost nothing to create. React can generate millions of them per second. The expensive work — updating the real DOM — only happens after React has determined the minimum set of changes required.
Reconciliation: The Diffing Algorithm
Reconciliation is the process of comparing a new Virtual DOM tree with the previous one and figuring out what changed. React then applies only those changes to the real DOM.
Consider a list that changes from three items to four:
// Before update
<ul>
<li>Apple</li>
<li>Banana</li>
<li>Cherry</li>
</ul>
// After update (new item added at the end)
<ul>
<li>Apple</li>
<li>Banana</li>
<li>Cherry</li>
<li>Date</li> {/* only this node is added to the real DOM */}
</ul>React sees that three list items are unchanged and creates only one new DOM node for "Date". Without the Virtual DOM, naive code might replace the entire ul with new innerHTML.
The diffing algorithm works under two key assumptions:
Type changes rebuild the subtree. If a
<div>becomes a<section>, React unmounts the entire<div>subtree (running all cleanup effects) and mounts a brand new<section>subtree. No reuse happens across type boundaries.Keys enable stable matching in lists. When React compares two lists, it uses the
keyprop to match old items with new items. Without keys, React falls back to position-based matching — which breaks if items are reordered or inserted in the middle.
React Fiber: The Rewrite That Enabled Concurrency
React 16 (2017) shipped a complete rewrite of the rendering engine called Fiber. The old engine (dubbed "Stack") rendered synchronously: once it started processing a component tree, it could not be interrupted. This caused "jank" — a large update would block the main thread, making animations stutter and inputs feel unresponsive.
Fiber represents each component as a "fiber" — a JavaScript object that contains the component's type, props, state, and pointers to its parent, child, and sibling. Instead of processing the tree recursively (and uninterruptibly), Fiber processes it as a linked list that can be paused, resumed, and prioritized.
Urgent updates (typing, clicking) get processed immediately
Non-urgent updates (rendering a large list after a filter) can be interrupted to keep the UI responsive
Concurrent Mode (React 18+) uses this to enable features like
useTransitionanduseDeferredValue
The Two Phases: Render and Commit
React's update cycle has two distinct phases:
1. The Render Phase (pure, can be interrupted)
React calls your component functions to get their JSX output
React builds a new Virtual DOM tree
React diffs the new tree against the previous one
React builds a list of DOM mutations to apply
This phase is pure — it produces no side effects, writes nothing to the DOM
In Concurrent Mode, this phase can be paused and restarted if higher-priority work arrives
2. The Commit Phase (synchronous, not interruptible)
React applies the calculated DOM mutations in one synchronous pass
React calls
useLayoutEffectcleanup and setup functions (synchronous)The browser paints the updated pixels
React calls
useEffectcleanup and setup functions (asynchronous, after paint)
function Component() {
// Runs during RENDER phase (must be pure — no DOM access, no side effects)
const [count, setCount] = useState(0)
const doubled = count * 2 // just a calculation, fine
// Runs during COMMIT phase (after DOM update, synchronous — blocks paint)
useLayoutEffect(() => {
// Safe to read/write DOM here — layout is stable
console.log('DOM updated, before paint')
}, [count])
// Runs after COMMIT phase (after paint — does not block the browser)
useEffect(() => {
console.log('After paint — good place for subscriptions, fetch calls')
return () => console.log('Cleanup before next run or unmount')
}, [count])
return <div>{doubled}</div>
}How a State Update Triggers a Re-render
Walk through exactly what happens when you call setState:
You call
setCount(count + 1)in an event handlerReact schedules a re-render for the component that owns
countReact batches any other state updates from the same event handler
React enters the render phase: it calls your component function with the new state value
Your component returns new JSX (a new Virtual DOM tree)
React diffs the new tree against the previous tree
React enters the commit phase: it applies the minimum DOM mutations
The browser repaints
React runs
useEffectcleanup and setup
A critical nuance: a "re-render" means React calls your component function again. It does not mean React updates the entire DOM. Re-rendering is cheap; DOM updates are expensive. React calls your function to produce a new VDOM tree, then only touches the DOM nodes that actually changed.
Automatic Batching in React 18
Before React 18, batching (combining multiple state updates into a single re-render) only happened inside React event handlers. Updates inside setTimeout, Promise.then, or native event listeners each triggered separate re-renders.
// React 18: ALL of these are batched into one re-render
setTimeout(() => {
setCount(c => c + 1) // does NOT immediately re-render
setFlag(f => !f) // does NOT immediately re-render
// React waits until here, then re-renders once
}, 1000)
// React 17 and below: each setState caused a separate re-render (3 re-renders total)
// React 18: React batches them automatically (1 re-render total)Automatic batching is a free performance improvement in React 18. You rarely need to think about it, but knowing it exists helps explain why console.log inside a component runs fewer times than you might expect.