Skip to main content

Command Palette

Search for a command to run...

How React's Virtual DOM Actually Works: A Step-by-Step Mental Model

Published
11 min read
How React's Virtual DOM Actually Works: A Step-by-Step Mental Model

If you've been working with React for a while, you've definitely heard the term "Virtual DOM." You might even know it has something to do with performance. But do you really understand what it is, why it exists, and how React uses it to keep your UI fast?

In this article, we'll build a clear mental model — step by step — from the problem of slow DOM manipulation all the way to how React decides what (and what not) to update on screen.

No Fiber internals. No low-level implementation details. Just the mental model you actually need.


The Problem: Direct DOM Manipulation Is Expensive

Before React, the standard approach was to manipulate the DOM directly — with vanilla JavaScript or jQuery.

This worked fine for small pages. But as apps grew, a pattern emerged: every time something changed (a user typed in an input, a list item was added, a counter incremented), developers would reach into the DOM and update it directly.

document.getElementById('count').textContent = newCount;
document.querySelector('.user-list').innerHTML = buildListHTML(users);

The problem? The DOM is expensive to touch.

Every time you read from or write to the DOM, the browser may need to:

  • Recalculate styles (style computation)

  • Recompute the layout of elements (reflow / layout)

  • Repaint affected areas on screen

These operations are not cheap. And when they happen on every keystroke, every data update, every state change — across a complex app with hundreds of elements — performance degrades fast.

The real bottleneck isn't JavaScript itself. JavaScript is fast. The bottleneck is the bridge between JavaScript and the browser's rendering engine. Every crossing of that bridge has a cost.

flowchart LR
    A["JS DOM write"] --> B["Style recalculation"]
    B --> C["Reflow / Layout"]
    C --> D["Repaint"]
    D --> E["Screen update"]

Every single DOM write can cascade through all of these steps — even for a one-character text change.


The Real DOM vs. The Virtual DOM

Let's get the distinction clear before going further.

The Real DOM

The Real DOM (Document Object Model) is the browser's structured, live representation of your HTML. Every element on the page — every <div>, <p>, <button> — is a node in a tree. When you change a node, the browser reacts immediately: it recalculates layout, triggers repaints, and updates what's on screen.

The Real DOM is powerful, but it's also heavyweight. Each node carries a lot of associated data — computed styles, event listeners, layout geometry. Touching it unnecessarily, or touching more of it than you need to, wastes time.

The Virtual DOM

The Virtual DOM is React's answer to this problem. It's not a browser API — it's a concept implemented entirely in JavaScript.

The Virtual DOM is simply a lightweight JavaScript object (a plain tree of objects) that describes what the UI should look like. It mirrors the structure of the Real DOM, but it's completely detached from the browser. Reading and writing to it is just object manipulation in memory — fast, cheap, and consequence-free.

Here's an oversimplified look at what a Virtual DOM node looks like:

// What React creates internally when you write JSX
{
  type: 'div',
  props: {
    className: 'card',
    children: [
      {
        type: 'h2',
        props: { children: 'Hello, World' }
      },
      {
        type: 'p',
        props: { children: 'Welcome back!' }
      }
    ]
  }
}

This is just a JavaScript object. No browser involvement. No layout recalculation. No repaint.

The key insight: React works with this cheap JavaScript tree first, and only touches the expensive Real DOM when it absolutely needs to.

Real DOM Virtual DOM
Managed by Browser React (in JS memory)
Update cost Expensive (reflow, repaint) Cheap (plain object mutation)
Triggers browser rendering Yes, immediately No
Purpose Actual UI on screen Computing what should change

Step 1 — Initial Render

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

  1. React evaluates your components. Your top-level component (say, <App />) renders, its children render, their children render — all the way down the tree.

  2. React builds a Virtual DOM tree. The result of all that rendering is a JavaScript object tree describing the full UI.

  3. React translates this to the Real DOM. React takes the Virtual DOM tree and creates actual DOM nodes in the browser — createElement, appendChild, and so on.

  4. The browser paints what it sees. The user now sees your UI.

flowchart TD
    A["Component Tree\n<App /> → <Header /> → <List />"]
    B["Virtual DOM Tree\n{ type: 'div', props: {...} }\nplain JS objects in memory"]
    C["Real DOM\n<div><header><ul>...\nbrowser creates and paints this"]

    A -->|"React renders components"| B
    B -->|"React commits to DOM"| C

At this point, React keeps a reference to the Virtual DOM tree it just created. This snapshot becomes important in the next step.


Step 2 — State or Props Change Triggers a Re-render

Now something happens. A user clicks a button. An API call returns data. A timer fires. Somewhere in your app, setState is called or a prop changes.

React needs to update the UI to reflect the new state. But it doesn't immediately reach into the Real DOM.

Instead, it does something much smarter.


Step 3 — React Creates a New Virtual DOM Tree

When state or props change, React re-renders the affected components — meaning it calls their render logic again and produces a new Virtual DOM tree representing what the UI should now look like.

This new tree exists entirely in memory. No browser touching. No layout. No paint. Just JavaScript objects.

flowchart TD
    A["User action\ne.g. button click"]
    B["setState called\nre-render scheduled"]
    C["Component function runs again"]
    D["New Virtual DOM tree produced\nexists only in JS memory"]

    A --> B --> C --> D

Now React has two Virtual DOM trees side by side:

  • The old tree — snapshot from the previous render

  • The new tree — just produced from the re-render

Both live in JavaScript memory. The browser has no idea any of this is happening yet.


Step 4 — Diffing (Reconciliation)

This is the heart of React's performance strategy.

React now compares the old Virtual DOM tree with the new one to find exactly what changed. This process is called diffing, and the broader algorithm that manages it is called reconciliation.

React walks both trees simultaneously, node by node, and asks: "Is this the same as before?"

flowchart LR
    subgraph OLD ["Old Virtual DOM"]
        O1["div.card"]
        O2["p: count 0"]
        O3["span: label"]
        O4["button: +"]
        O1 --> O2 & O3 & O4
    end

    subgraph NEW ["New Virtual DOM"]
        N1["div.card"]
        N2["p: count 1"]
        N3["span: label"]
        N4["button: +"]
        N1 --> N2 & N3 & N4
    end

    O2 -. "changed" .-> N2
    O3 -. "same" .-> N3
    O4 -. "same" .-> N4

Here's how React handles each node during diffing:

  • If a node's type changed (e.g. a <div> became a <span>), React tears out the entire old subtree and builds a fresh one.

  • If the type is the same but props changed (e.g. className or text content), React notes which specific attributes need updating.

  • If nothing changed on a node, React skips it entirely.

Keys and list diffing

React also uses key props on list items to make this comparison smarter. Without keys, React might mistake an item being removed for all subsequent items shifting — causing unnecessary re-renders. With keys, React can identify exactly which item was added, removed, or moved.

// Without key — React can't tell which item changed
{items.map(item => <li>{item.name}</li>)}

// With key — React tracks each item precisely
{items.map(item => <li key={item.id}>{item.name}</li>)}

O(n) diffing

React's diffing algorithm is designed to run in O(n) time (linear) rather than the theoretical O(n³) complexity of a naive tree comparison. This is possible because of some practical heuristics React makes about how UIs actually change — for example, that components rarely change type, and that siblings at the same level are usually stable.


Step 5 — Applying Minimal Updates to the Real DOM

After diffing, React has a precise, minimal patch — a list of only the changes that need to be made to the Real DOM.

Maybe out of 200 nodes in your component tree, only one <p> tag's text content actually changed. React will update only that one node.

flowchart TD
    A["Diff result:\nOnly p text changed — count 0 to 1"]
    B["Real DOM\n· div — unchanged\n· p  ← UPDATED\n· span — unchanged\n· button — unchanged"]
    C["Browser repaints only the affected area"]

    A -->|"React writes minimal patch"| B
    B --> C

This is the key performance win. Instead of rebuilding entire sections of the DOM (or doing a wholesale innerHTML replacement), React makes surgical, targeted updates — touching only the nodes that actually need to change.


Why This Approach Improves Performance

Let's tie it all together.

The Virtual DOM isn't fast because JavaScript is fast (though that helps). It's fast because of what it avoids.

By computing changes in memory first — comparing two plain JavaScript trees — React minimizes the number of times it has to touch the Real DOM. And since Real DOM operations are the expensive part, doing fewer of them (and doing them only where necessary) is what makes React UIs snappy.

Think of it like a diff tool for code. Instead of rewriting an entire file when one line changes, you apply only the changed lines. The Virtual DOM is React's "diff tool" for UI.


The Full Picture: Render → Diff → Commit

Here's the complete React update lifecycle in one diagram:

flowchart TD
    TRIGGER["State / Props change"]

    subgraph RENDER ["Render Phase — pure computation, interruptible"]
        R1["Run component functions"]
        R2["Build new Virtual DOM tree"]
        R3["Diff old tree vs new tree"]
        R1 --> R2 --> R3
    end

    subgraph COMMIT ["Commit Phase — synchronous DOM writes"]
        C1["Apply minimal patch to Real DOM"]
        C2["Run useEffect / lifecycle methods"]
        C3["Browser paints updated UI"]
        C1 --> C2 --> C3
    end

    TRIGGER --> RENDER
    RENDER -->|"List of changes"| COMMIT
Phase What Happens Nature
Render Components run, new Virtual DOM is produced, old vs new trees are diffed Pure computation — no side effects, can be paused
Commit Minimal patch is written to the Real DOM, effects are run Synchronous — DOM writes happen here

React keeps these two phases separate deliberately. The render phase is pure computation — no side effects, and in modern React it can even be paused or interrupted for higher-priority updates. The commit phase is where the actual DOM writes happen, and it runs synchronously to completion.


A Note on React Fiber

React's current architecture (React Fiber, introduced in React 16) builds on these ideas but goes further — it makes the render phase interruptible and prioritizable, so high-priority updates (like user input) can jump ahead of lower-priority ones (like background data fetches).

But Fiber's internal mechanics are a deeper dive for another article. The mental model above — Virtual DOM as a lightweight description, diffing to find minimal changes, committing those changes to the Real DOM — is the foundation that everything else is built on.


Summary

  • The Real DOM is expensive to update frequently due to reflow, repaint, and style recalculation costs.

  • The Virtual DOM is a lightweight JavaScript representation of the UI, kept entirely in memory.

  • On initial render, React builds a Virtual DOM tree and translates it to the Real DOM.

  • On state or props changes, React re-renders components and builds a new Virtual DOM tree in memory.

  • Diffing (reconciliation) compares the old and new trees to find exactly what changed.

  • React then applies a minimal patch to the Real DOM — touching only what needs to change.

  • This approach dramatically reduces unnecessary browser work, keeping UIs fast and efficient.

The next time you call setState, remember: React isn't updating the DOM. It's updating a JavaScript object first, doing the math, and then making only the moves that matter.


Found this useful? Share it with someone learning React. Drop any questions in the comments below!