ReactHow React Works (Virtual DOM)

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:

JSX
// 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:

JSX
// 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 key prop 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.

Warning
Never use array indexes as keys for lists that can be reordered, filtered, or have items added/removed. Index keys tell React "item at position 2 is the same item," which causes wrong state to stick to the wrong element. Use stable, unique IDs from your data instead.
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 useTransition and useDeferredValue

Note
You do not interact with Fiber directly. It is an internal implementation detail. But understanding that it exists explains why React 18's concurrent features (Suspense, useTransition, automatic batching) are possible — they all rely on Fiber's interruptible rendering.
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 useLayoutEffect cleanup and setup functions (synchronous)

  • The browser paints the updated pixels

  • React calls useEffect cleanup and setup functions (asynchronous, after paint)

JSX
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 handler

  • React schedules a re-render for the component that owns count

  • React 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 useEffect cleanup 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.

Visualizing this in the browser
Open Chrome DevTools → Performance tab. Record while interacting with a React app. You will see `(anonymous)` JavaScript entries (render phase) followed by `Recalculate Style` and `Layout` entries (commit phase causing browser work). This makes the two phases tangible.
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.

JSX
// 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.