Ok, before we go into the depths of these concepts, I want to tell you that we will take it easy. I don't want you to get overwhelmed by the jargon. Virtual DOM, reconciliation, diffing, these words sound like they belong in a research paper, not a blog post.
Here is the thing. Every React developer has heard "React is fast because of the Virtual DOM." And every React developer has nodded along. But if you ask them why the Virtual DOM makes it fast, most of them go quiet.
Let's fix that.
The Problem: Why Touching the DOM Directly Hurts
Think about the last time you had to repaint an entire wall just to fix one scratch near the skirting board.
That's what browsers do when you manipulate the DOM without strategy. The browser doesn't just change the one element you touched; it recalculates styles, reflows the layout, and repaints everything that might have been affected. One small change, full repaint.
Now, imagine a user clicks "like" on one post in a feed of 500. Without any optimization, the browser re-renders the whole feed. Sounds like a massive headache, right?
This is the exact problem the Virtual DOM was built to solve.
The Real DOM: Why It's So Expensive
The DOM (Document Object Model) is a tree-like representation of your HTML that browsers maintain internally. Every element is a live node with styles, event listeners, and layout information all attached to it.
Let me explain it in simple words.
Every direct DOM operation triggers a chain reaction inside the browser. document.getElementById('title').innerText = 'Hello' sounds innocent. But the browser recalculates styles, re-runs layout for anything affected, and queues a repaint. It is not broken, it is just expensive by design.
For a static webpage, this is fine. For an app updating 30 times a second, it becomes a serious bottleneck.
The Virtual DOM: The Architect's Blueprint
You might be thinking, "Why not just be more careful about which DOM updates you trigger? Why add an entire extra layer?"
Fair question. For a small app, maybe you could manage this manually. But the moment you have dozens of components all reacting to user input, server responses, timers, and route changes, tracking what changed and what didn't becomes a full-time job on its own.
The Virtual DOM hands that job to React.
Think of an architect before a renovation. They don't tear down walls to figure out what to change. They work on a blueprint first, a lightweight paper representation of the building. Once all the changes are clear on paper, they hand over a precise list to the construction crew. The crew does only what's on that list. Not the whole building.
The Virtual DOM is React's blueprint. It is a plain JavaScript object that mirrors the structure of your UI, but with none of the expensive browser machinery attached.
// A simplified Virtual DOM node looks something like this
{
type: 'div',
props: { className: 'post-card', id: 'post-42' },
children: [
{
type: 'h2',
props: {},
children: ['React Virtual DOM Explained']
},
{
type: 'span',
props: { className: 'likes' },
children: ['128 likes']
}
]
}
This is a plain JavaScript object. Creating and comparing these costs almost nothing, no layout engine, no browser API calls, no repaint.
The Initial Render
When your React app loads for the first time, React runs your component functions, converts the returned JSX into a Virtual DOM tree, and uses that tree to build and paint the actual Real DOM nodes.
The first render is always a full build. There's no way around it, you're starting from nothing. The magic kicks in when something changes.
State or Props Change: Drawing a New Blueprint
When a user clicks a button, submits a form, or new API data arrives, React state or props update.
React does not immediately reach for the Real DOM. Instead, it re-runs the affected component functions and builds a brand new Virtual DOM tree, a fresh blueprint of what the UI should now look like.
You now have two blueprints side by side: the old one (what's on screen) and the new one (what it should look like after the change).
Sounds familiar, right? This is where diffing comes in.
Diffing and Reconciliation: The Spot-the-Difference Game
Reconciliation is the process by which React compares the old Virtual DOM tree with the new one to determine the minimal set of changes required to update the Real DOM.
Let me explain it in simple words.
You've played "spot the difference" puzzles, two nearly identical images, five things changed. React plays this game but with JavaScript tree structures.
It walks through both trees simultaneously, node by node. Where nothing changed, it does nothing. Where something changed, it records the operation: update this text, swap this class, remove this node entirely. At the end, React holds a precise, minimal patch.
React does this using a few rules:
- Elements of different types (a
<div>becoming a<section>) get torn down and rebuilt from scratch. - Elements of the same type get their props compared; only the changed props get updated.
-
Lists use
keyprops to track which items were added, removed, or moved. This is exactly why React warns you when you forget to add keys.
Insane, right? You write setLikeCount(c => c + 1) and React plays spot-the-difference on your entire component tree to figure out the smallest possible set of Real DOM operations to run.
Applying the Diff
Once React has the patch, it enters the commit phase, the only moment it actually touches the Real DOM.
It applies the diff in order, synchronously. Insert this node. Update that attribute. Remove this one. Instead of re-rendering your 500-post feed, React updates the one <span> whose like count changed. The browser recalculates layout for that tiny corner of the screen, not the whole page.
That's why React apps feel snappy even as they grow complex.
The Real Engineering Part
Here is the full render-to-commit lifecycle, without the hand-waving.
Trigger → Render Phase → Commit Phase
Trigger
A state or props change schedules a re-render. React doesn't process it immediately; it batches multiple updates from the same event handler into a single pass.
// Both updates collapse into ONE re-render, ONE diff, ONE commit
const handleLike = () => {
setLikeCount(c => c + 1);
setHasLiked(true);
};
Two state updates. One re-render. React collects them, rebuilds the Virtual DOM once, diffs once, commits once.
Render Phase
React calls the component functions. They return JSX, which becomes new Virtual DOM nodes. React builds the new tree, then diffs it against the previous one.
This phase is pure, no Real DOM contact. If React needs to interrupt this phase (in Concurrent Mode), it can because no real side effects have happened yet.
Commit Phase
React applies the diff to the Real DOM synchronously, in order. After the DOM is updated, it fires your useEffect hooks.
useEffect(() => {
// Runs AFTER React has committed all changes to the Real DOM
document.title = `${likeCount} people liked this`;
}, [likeCount]);
useEffect fires after commit, not during. This guarantees the DOM is fully updated before your effect runs.
The three phases: Render → Diff → Commit repeat on every state or props change. Understanding this loop is what separates React developers who write fast apps from those who keep wondering why their app lags.
The Catch: When the Virtual DOM Is Not Enough
You might be thinking, "If the Virtual DOM handles all this automatically, why do React apps still go slow sometimes?"
Because the Virtual DOM optimizes Real DOM writes. It doesn't optimize component execution.
If a parent component re-renders, all its children re-render by default even if their props didn't change. React diffs those children, confirms nothing changed, skips the DOM update. Correct behavior. But you still paid the cost of running every child function and building every child's Virtual DOM nodes.
In a small app, this is invisible. In a large one, it destroys your frame rate.
Let me share a very interesting problem I faced where this bit me hard. I was building a theme editor, the kind where users pick brand colors and see a live preview of their UI update in real time. Dragging the hue slider on the color picker was visibly janky. The preview felt like it was running at 10fps.
I dropped React Scan into the project. If you haven't used it, React Scan overlays a colored flash on every component that re-renders, you literally watch your UI light up in real time. The moment I moved the hue slider, the entire page lit up. The color picker was sitting inside a parent that held the theme state. Every onColorChange fired a setState on that parent. That parent re-rendered. Every child of that parent, the font selector, the spacing controls, the preview card, the export button, all of them re-rendered on every single mousemove event.
The Virtual DOM was correctly diffing all of them and making zero DOM changes for most of them. But the render phase was still executing every single one of those component functions, 60 times a second, as the user dragged.
The fix was two things: React.memo on every sibling component that didn't consume the color state, and moving the color state down into a smaller subtree closer to where it was actually used. React Scan went quiet. The slider felt instant.
The Virtual DOM wasn't the problem. The unchecked render phase was. React.memo, useMemo, and useCallback are the tools you reach for when the render phase is your bottleneck, and React Scan is how you find out you have the problem in the first place.
DIY: Build It Yourself (Minimal Reconciler)
You don't need to build a full React reconciler for this to click. Three small functions are enough.
1. A Minimal Virtual DOM Node
// vdom.js
function createElement(type, props = {}, ...children) {
return { type, props, children: children.flat() };
}
const vNode = createElement(
'div',
{ id: 'post-card' },
createElement('h2', {}, 'React Virtual DOM'),
createElement('span', { className: 'likes' }, '128 likes')
);
console.log(JSON.stringify(vNode, null, 2));
Plain JavaScript object. No browser API. No paint. Creating a tree like this is essentially free.
2. A Minimal Differ
// differ.js
function diff(oldNode, newNode) {
if (!newNode) return { type: 'REMOVE' };
if (!oldNode) return { type: 'ADD', newNode };
if (typeof oldNode !== typeof newNode) return { type: 'REPLACE', newNode };
if (typeof oldNode === 'string') {
return oldNode !== newNode ? { type: 'TEXT_UPDATE', newNode } : { type: 'NONE' };
}
if (oldNode.type !== newNode.type) return { type: 'REPLACE', newNode };
const propChanges = {};
const allProps = new Set([
...Object.keys(oldNode.props || {}),
...Object.keys(newNode.props || {})
]);
for (const key of allProps) {
if ((oldNode.props || {})[key] !== (newNode.props || {})[key]) {
propChanges[key] = (newNode.props || {})[key];
}
}
return {
type: 'UPDATE',
propChanges,
childDiffs: diffChildren(oldNode.children || [], newNode.children || [])
};
}
function diffChildren(oldChildren, newChildren) {
const length = Math.max(oldChildren.length, newChildren.length);
const diffs = [];
for (let i = 0; i < length; i++) {
diffs.push(diff(oldChildren[i], newChildren[i]));
}
return diffs;
}
Call diff(oldTree, newTree) and inspect the output. You'll see NONE where nothing changed, TEXT_UPDATE where text changed, REPLACE where a node type flipped. That object is the patch only what needs work.
3. Apply the Diff to the Real DOM
// commit.js
function applyDiff(domNode, patch) {
if (patch.type === 'NONE') return;
if (patch.type === 'REMOVE') {
domNode.parentNode.removeChild(domNode);
return;
}
if (patch.type === 'ADD') {
domNode.parentNode.appendChild(createDomNode(patch.newNode));
return;
}
if (patch.type === 'REPLACE') {
domNode.parentNode.replaceChild(createDomNode(patch.newNode), domNode);
return;
}
if (patch.type === 'TEXT_UPDATE') {
domNode.textContent = patch.newNode;
return;
}
// UPDATE — apply only the changed props
for (const [key, value] of Object.entries(patch.propChanges)) {
if (value === undefined) {
domNode.removeAttribute(key);
} else {
domNode.setAttribute(key, value);
}
}
patch.childDiffs.forEach((childPatch, i) => {
applyDiff(domNode.childNodes[i], childPatch);
});
}
The commit function does exactly what the diff says, nothing more. A node with no changes? Skipped. One changed attribute? One setAttribute call.
Try implementing this! It is one thing to read about reconciliation, but it is a whole different feeling when you build two trees by hand, run diff(), and watch it produce a minimal, precise patch for exactly the nodes that changed.
Happy Exploration!



Top comments (0)