Skip to main content

Command Palette

Search for a command to run...

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

Updated
9 min read
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 state change. Instead, it builds a lightweight JavaScript copy of the DOM (Virtual DOM), compares the new version against the old one (diffing/reconciliation), and applies only the minimal set of changes to the Real DOM. This post explains exactly how that process works.

Audience: This post assumes working knowledge of React (components, state, props) and basic JavaScript. No Fiber internals — we're focused on the mental model and the lifecycle.


Problem

The browser's Real DOM is slow to manipulate — not because reading it is expensive, but because writing to it triggers cascading work.

Every time you touch the DOM:

  • The browser may trigger reflow (recalculating layout geometry)
  • Then repaint (re-rendering pixels on screen)
  • Reflow is especially expensive — changing one element's size can invalidate the layout of dozens of others

In a jQuery-era app, you'd imperatively update DOM nodes on every interaction:

// Old-school direct DOM manipulation
document.getElementById('counter').innerText = newCount;
document.getElementById('status').className = 'active';
document.getElementById('badge').style.display = 'block';

This works for 3 updates. It becomes a maintenance and performance nightmare at scale — especially when multiple state changes happen in quick succession and you're manually deciding what to update.

React's Virtual DOM exists to solve one problem: how do you efficiently figure out the minimal set of Real DOM changes needed after any state or props update?


Solution: The Virtual DOM Mental Model

Before diving into the lifecycle, here's the core idea:

The Virtual DOM is a plain JavaScript object tree that mirrors the structure of your UI. It's cheap to create, cheap to compare, and lives entirely in memory — no browser involvement.

When your UI needs to update, React:

  1. Builds a new Virtual DOM tree
  2. Diffs it against the previous Virtual DOM tree
  3. Computes the minimal patch
  4. Applies only that patch to the Real DOM

This means the expensive Real DOM operations are batched and minimized — React is doing the smart work so the browser doesn't have to.


Step 1: What is the Virtual DOM?

A Virtual DOM node (called a React Element) is just a JavaScript object:

// What JSX compiles to
const element = React.createElement('div', { className: 'card' },
  React.createElement('h2', null, 'Hello'),
  React.createElement('p', null, 'World')
);

// The resulting plain JS object:
{
  type: 'div',
  props: {
    className: 'card',
    children: [
      { type: 'h2', props: { children: 'Hello' } },
      { type: 'p',  props: { children: 'World' } }
    ]
  }
}

This object has no connection to the browser. Creating it costs almost nothing — it's just allocating memory for a JavaScript object.

Compare that to creating a Real DOM node:

// This touches the browser engine — significantly heavier
const realDiv = document.createElement('div');
// A real DOM node has ~250+ properties attached to it by the browser
console.log(Object.keys(Object.getPrototypeOf(realDiv)).length); // ~70+ methods alone

Virtual DOM objects are orders of magnitude lighter.


Step 2: Initial Render — Component → Virtual DOM → Real DOM

When your React app first loads, here's what happens:

[Your Components]
      ↓  React calls render / returns JSX
[Virtual DOM Tree built in memory]
      ↓  React DOM commits to browser
[Real DOM nodes created and inserted]
      ↓
[Browser paints the screen]

Concrete example:

// Counter.jsx
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div className="counter">
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

On initial mount, React:

  1. Calls Counter() → gets back a JSX tree
  2. Converts JSX to a Virtual DOM tree (nested JS objects)
  3. Walks that tree and creates actual div, h1, button DOM nodes
  4. Inserts them into document.body (or your root element)
  5. Stores the Virtual DOM tree in memory as the current tree

The key point: React keeps a copy of the Virtual DOM tree after every render. This is what enables diffing.


Step 3: State Change Triggers a New Virtual DOM Tree

When the user clicks "Increment":

setCount(1) called
      ↓
React schedules a re-render
      ↓
Counter() is called again → returns new JSX
      ↓
New Virtual DOM tree is built in memory

The new Virtual DOM tree for count = 1:

// New tree (count = 1)
{
  type: 'div',
  props: {
    className: 'counter',
    children: [
      { type: 'h1', props: { children: 'Count: 1' } },  // ← changed
      { type: 'button', props: { children: 'Increment', onClick: [fn] } }
    ]
  }
}

The previous Virtual DOM tree (still in memory):

// Old tree (count = 0)
{
  type: 'div',
  props: {
    className: 'counter',
    children: [
      { type: 'h1', props: { children: 'Count: 0' } },  // ← old value
      { type: 'button', props: { children: 'Increment', onClick: [fn] } }
    ]
  }
}

Now React needs to figure out: what actually changed?


Step 4: Diffing (Reconciliation)

Reconciliation is React's algorithm for comparing the old Virtual DOM tree against the new one and computing the minimal set of Real DOM operations.

How React Diffs Two Trees

Naive tree diffing is O(n³) — comparing every node against every other node across two trees. For a tree with 1000 nodes, that's 1 billion comparisons. Unusable.

React uses two heuristics to reduce this to O(n):

Heuristic 1: Same type = update, different type = replace

// Before
<div className="counter" />

// After
<div className="counter active" />  // ← same type (div), React updates the attribute

// Before
<div />

// After
<span />  // ← different type, React destroys div tree and builds span tree from scratch

Heuristic 2: Keys identify stable list items

// Without keys — React can't tell which item moved
<ul>
  <li>Apple</li>
  <li>Banana</li>
</ul>

// With keys — React tracks identity across renders
<ul>
  {fruits.map(fruit => (
    <li key={fruit.id}>{fruit.name}</li>  // ← key lets React match old → new nodes
  ))}
</ul>

Without keys, inserting an item at the top of a list causes React to re-render every list item. With keys, React correctly identifies only the new item was added.

What the Diff Produces

For our Counter example, the diff result is:

Diff result:
- div.counter       → no change (same type, same className)
  - h1              → no change (same type)
    - text node     → UPDATE: "Count: 0""Count: 1"   ← only this
  - button          → no change
    - text node     → no change
    - onClick       → no change (React handles this separately via delegation)

One text node update. That's it.


Step 5: Commit Phase — Applying Minimal Updates to the Real DOM

After reconciliation produces the diff, React enters the commit phase: it applies the computed changes to the actual browser DOM.

// React internally does something equivalent to this:
// (Pseudocode — actual implementation uses Fiber work units)

function commitUpdate(domNode, oldProps, newProps) {
  // Only update properties that changed
  for (const key in newProps) {
    if (oldProps[key] !== newProps[key]) {
      if (key === 'children' && typeof newProps[key] === 'string') {
        domNode.textContent = newProps[key];  // Direct, targeted update
      } else if (key !== 'children') {
        domNode[key] = newProps[key];
      }
    }
  }
}

For our counter: React calls textNode.nodeValue = 'Count: 1' — a single DOM write.

The browser then repaints just that text node's region, not the entire page.


Step 6: Full React Update Lifecycle (Visual Flow)

┌─────────────────────────────────────────────────────────┐
│                   INITIAL RENDER                         │
│                                                         │
│  Component() ──► Virtual DOM Tree ──► Real DOM          │
│  (JSX returned)   (JS objects)       (browser nodes)    │
│                         │                               │
│                   Stored as                             │
│                  "current tree"                         │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                   STATE UPDATE                           │
│                                                         │
│  setState() called                                      │
│       │                                                 │
│       ▼                                                 │
│  Component() re-runs ──► New Virtual DOM Tree           │
│                                │                        │
│                         RECONCILIATION                  │
│                    (old tree vs new tree)               │
│                                │                        │
│                     Diff computed                       │
│                    [only changes]                       │
│                                │                        │
│                      COMMIT PHASE                       │
│                    Patch → Real DOM                     │
│                                │                        │
│                    Browser repaints                     │
│                  affected nodes only                    │
└─────────────────────────────────────────────────────────┘

Why This Improves Performance

Three concrete reasons:

1. Batching: React can batch multiple setState calls into a single re-render cycle, meaning 10 state updates in one event handler produce one diff and one commit — not 10 separate DOM writes.

function handleSubmit() {
  setLoading(true);
  setError(null);
  setData(response);  // These three setState calls
  // React batches them → ONE re-render, not three
}

2. Minimal DOM writes: Real DOM writes trigger reflow/repaint. By computing the diff in pure JS first, React ensures each commit is as small as possible.

3. No manual DOM tracking: Without Virtual DOM, you'd need to manually track what changed and update only those elements — error-prone at scale. React automates this correctly.


Results

The Virtual DOM approach delivers measurable benefits in realistic scenarios:

  • List with 500 items, one item updated: Without Virtual DOM → potentially 500 DOM node replacements. With React → 1 targeted text node update.
  • Form with 20 fields, one field changes: React commits changes to exactly 1 input's value attribute, not all 20.
  • Batched state updates: A component calling setState 5 times synchronously produces exactly 1 DOM commit cycle.

Trade-offs

The Virtual DOM is not free. Be honest about its costs:

Trade-offDetail
Memory overheadReact maintains two Virtual DOM trees in memory (current + work-in-progress). For very large UIs, this is non-trivial.
Overhead for simple UIsFor a static page or a single counter, the Virtual DOM adds overhead compared to a direct element.textContent = value. The benefit appears at scale.
Diffing has a costThe O(n) diff algorithm still processes every component in the tree. React.memo and useMemo exist specifically to short-circuit this when subtrees haven't changed.
Not always the fastest optionFrameworks like Svelte compile reactivity at build time and skip the Virtual DOM entirely — for some use cases, this produces smaller bundles and faster runtime.

When NOT to Rely on Virtual DOM Alone

  • Animations at 60fps: React's reconciliation cycle adds latency. Use CSS animations or direct requestAnimationFrame loops for frame-critical animations.
  • Canvas / WebGL rendering: These bypass the DOM entirely. React is irrelevant here.
  • Large static lists: Consider windowing libraries (react-window, react-virtual) — they reduce the Virtual DOM tree size by only rendering visible items, which is more impactful than the diff algorithm.

Conclusion

The Virtual DOM solves a real engineering problem: how to efficiently sync a component tree to a browser DOM when state changes frequently and unpredictably.

The key takeaways:

  1. Virtual DOM is a cheap in-memory JS object tree — not a browser concept
  2. React always keeps the previous tree to enable comparison
  3. Reconciliation runs in JS (fast), Real DOM writes are minimized (slow part avoided)
  4. The two heuristics (same type = update, keys for lists) make diffing O(n)
  5. The commit phase applies only the computed delta to the Real DOM

Next step: Now that you understand the Virtual DOM mental model, explore React's key prop in depth — it's the most common source of reconciliation bugs and the most impactful lever you have over diff behavior in list rendering.


Further Reading