React Virtual DOM Explained: How It Works Under the Hood
Introduction
If you've ever built something with vanilla JavaScript and had to update the UI frequently, you already know the pain. You call document.getElementById, change some text, update a class, maybe re-render a list. It works. But at some point, as the app grows, it slows to a crawl. Pages feel sluggish. Simple interactions feel heavier than they should.
That sluggishness has a cause. And the Virtual DOM is React's answer to it.
The Problem with Direct DOM Manipulation
The real DOM (Document Object Model) is a tree of nodes that the browser builds from your HTML. Every time you touch it, the browser has to do work.
Change a single element's text? The browser may reflow the layout. Add a class? It might repaint parts of the screen. Remove a child node and add a new one? It recalculates styles, repaints, and composites everything again.
These operations are not free. The DOM was built to represent documents, not dynamic user interfaces that update dozens of times per second. When you write a loop that calls innerHTML repeatedly, or update many nodes in quick succession, you're forcing the browser to redo expensive work on every single change.
React solves it by introducing a layer between your code and the real DOM.
What the Virtual DOM Actually Is
The Virtual DOM is not magic. It's a plain JavaScript object that describes what the UI should look like.
When you write JSX like this:
const element = <h1 className="title">Hello</h1>;
React converts it into something like this under the hood:
const element = {
type: 'h1',
props: {
className: 'title',
children: 'Hello'
}
};
That's it. A JavaScript object. No browser APIs involved. No layout recalculations. Just a description of a node.
A full component tree becomes a tree of these objects. React holds this in memory and uses it to figure out what, if anything, needs to change in the real DOM. Since reading and writing plain JavaScript objects is orders of magnitude faster than touching the DOM, React can do a lot of computation cheaply before it ever commits a single change to the browser.
Step 1: The Initial Render
When your app loads for the first time, React builds a Virtual DOM tree from your component tree, then walks it once to generate the corresponding real DOM nodes and insert them into the page.
App
└── Header
│ └── h1: "My App"
└── Main
└── p: "Welcome"
React creates the equivalent real DOM elements, attaches event listeners, and paints the initial UI. This first render has no diffing to do. It just builds.
Step 2: Something Changes
Now a user clicks a button. You call setState. A prop changes. React knows something has changed, so it needs to figure out what the new UI should look like.
The key thing here: React does not immediately touch the real DOM.
Instead, it re-runs your component functions (or render methods) to produce a brand new Virtual DOM tree that reflects the new state.
// Old Virtual DOM tree
App
└── Counter
└── p: "Count: 0"
└── button: "Increment"
// New Virtual DOM tree (after state change)
App
└── Counter
└── p: "Count: 1" <--- changed
└── button: "Increment"
Both trees exist in memory. Now React has to compare them.
Step 3: Diffing (Reconciliation)
Reconciliation is the process of comparing the old Virtual DOM tree with the new one, node by node, to find what changed.
React calls this diffing. Walking through the two trees looks roughly like this:
Old Tree New Tree
----------- -----------
App App
└── Counter └── Counter
└── p: "0" └── p: "1" <- different
└── button └── button <- same
React starts at the root and walks both trees in parallel. For each node, it asks: is this the same type of element? Do the props match? What about the children?
In this case, it finds one difference: the text content of the p tag changed from "0" to "1". Everything else is identical.
React does not re-render the button. It does not re-render the Counter wrapper. It marks only that one p node as needing an update.
A few rules React uses to make this fast:
Elements of different types (like swapping a
divfor aspan) are treated as a full replacement. The old subtree is discarded and a new one is built.For elements of the same type, React updates only the changed attributes or children.
For lists, React uses
keyprops to match old items to new ones. Without keys, React has to guess, which can produce wrong results.
Step 4: The Commit Phase
Once diffing is done, React has a list of the minimal changes it needs to make. It then applies those changes to the real DOM in a single batch.
In the example above, it does one thing: updates the text content of that paragraph. Not the button. Not the wrapper. Just the text.
// Only this real DOM operation runs:
paragraphNode.textContent = "Count: 1";
This batching is where the performance benefit becomes real. Instead of updating the DOM after every setState call individually, React collects all the changes from a render cycle and applies them at once. The browser does one reflow and one repaint, not many.
The Full Flow
Here's the flow from start to finish:
[State/Props Change]
|
v
[React re-runs components]
[New Virtual DOM tree is created]
|
v
[Diffing: old tree vs new tree]
[Minimal list of changes is computed]
|
v
[Commit phase: changes applied to real DOM]
|
v
[Browser repaints]
React separates the "figuring out what changed" step (render and diff) from the "actually changing the DOM" step (commit). The first part is cheap. The second part is expensive. By minimizing how often and how much of the second part runs, React keeps UIs fast.
Is the Virtual DOM Always Worth It?
You might write a component that re-renders on every keystroke. Without Virtual DOM, that would mean dozens of DOM writes per second. With React's approach, most of those re-renders produce a Virtual DOM that's nearly identical to the previous one. After diffing, maybe only one input's value attribute gets updated in the real DOM.
The Virtual DOM doesn't eliminate DOM work. It reduces it to only what's necessary.
There's a tradeoff worth knowing: for extremely simple UIs, the overhead of maintaining a Virtual DOM and diffing it isn't worth it. Vanilla JavaScript can be faster for small, static pages. But once your UI has real complexity and frequent updates, the Virtual DOM approach pays off quickly.
Conclusion
The mental model here, render to a virtual tree, diff it against the previous tree, commit only the changes, is accurate enough to write better React code, debug re-render issues, and understand why key props matter in lists.
That's the Virtual DOM. A JavaScript object tree, a comparison algorithm, and a batched write to the real DOM. Nothing more, nothing less.



