How React Works Internally: Virtual DOM, Reconciliation, and the Rendering Pipeline

Search for a command to run...

No comments yet. Be the first to comment.
React Hooks Masterclass: useState, useEffect, and Custom Hooks Explained TL;DR: React Hooks replaced class components by giving functional components the ability to manage state, run side effects, an

How React Works Internally: Virtual DOM, Reconciliation, and the Rendering Pipeline TL;DR: When you call setState, React doesn't immediately touch the browser DOM. Instead, it re-executes your compon

Mastering TypeScript: Interfaces, Generics, and Union Types Explained TL;DR: TypeScript adds a compile-time type system on top of JavaScript that catches entire classes of bugs before your code ships

Mastering TypeScript: Interfaces, Generics, and Union Types Explained TL;DR: TypeScript adds a compile-time type system on top of JavaScript that catches entire classes of bugs before your code ships

TL;DR: When you call
setState, React doesn't immediately touch the browser DOM. Instead, it re-executes your component function, builds a new Virtual DOM tree, diffs it against the previous one, and then makes the minimum set of real DOM changes needed. Understanding this pipeline end-to-end is the difference between a developer who uses React and one who truly controls it.
Here's the question every React developer eventually confronts: What actually happens when you call setState?
Most people can answer at the surface level — "React re-renders the component." But that answer conceals an entire engine. What does re-render mean, precisely? Does React rebuild the whole page? Does it touch every DOM node? How does it know what changed? Why does moving a key prop cause a component to unmount and remount instead of update?
These aren't academic questions. They directly affect performance, correctness, and your ability to debug production issues. This article builds the complete mental model: from why the real DOM is expensive, through how the Virtual DOM works, through reconciliation and the diffing algorithm, all the way to the commit phase where pixels finally change on screen.
Target audience: React developers who are comfortable with hooks and component state but want to understand what React does under the hood. We will deliberately avoid Fiber scheduler internals — the goal is a clear, accurate mental model, not an implementation audit.
Before React, the dominant model was imperative DOM manipulation — you wrote code that directly told the browser what to change. jQuery made this ergonomic, but it didn't change the fundamental mental model: find an element, change it.
This works fine for simple pages. It breaks down badly when your UI has dozens of components whose visibility, content, and state depend on each other. You'd end up writing synchronization logic by hand: "when the user object changes, find all the DOM nodes that show the username and update each of them." Bugs lived in the gaps between those updates.
The browser DOM is a live object graph that connects your HTML to the rendering engine. Every time you change it, the browser may need to perform two expensive operations:
Reflows are particularly brutal because they can be triggered by reading certain layout properties (offsetHeight, getBoundingClientRect) immediately after writing others — a pattern called layout thrashing.
Note: Not every DOM change causes a full reflow. Changing
colortriggers a repaint but not a reflow. Changingwidthtriggers both. The browser is smart about batching, but it can only do so much when you're making dozens of uncoordinated changes.
With a complex UI — think a live dashboard with 50 data points updating every second — direct DOM manipulation at that frequency will visibly stutter.
React shifts the mental model from imperative ("do this to the DOM") to declarative ("this is what the UI should look like given this state"). You describe the desired output; React figures out the minimal mutations to get there. This requires React to maintain its own model of the UI, compare it to the previous model when state changes, and derive a patch. That model is the Virtual DOM.
The DOM (Document Object Model) is the browser's internal representation of your HTML as a tree of objects. Each HTML tag becomes a node. Each node is a full JavaScript object with hundreds of properties — styles, event listeners, geometry information, accessibility attributes, and more.
// Accessing a real DOM node
const button = document.getElementById('submit-btn');
console.log(Object.keys(button).length); // hundreds of properties
Creating and modifying these objects is orders of magnitude more expensive than working with plain JavaScript objects, because the browser has to keep its rendering tree in sync with every change. This is the core performance insight that motivates the Virtual DOM.
The Virtual DOM is React's lightweight JavaScript representation of what the UI should look like. It is a plain object tree — no browser APIs, no rendering side effects. Creating and comparing these objects is fast because they're just memory operations.
Here is what a Virtual DOM node looks like (simplified):
// A React element — the Virtual DOM node
{
type: 'button',
props: {
className: 'btn btn-primary',
onClick: handleClick,
children: 'Save Changes'
},
key: null,
ref: null
}
When your component returns JSX, Babel transforms it into React.createElement calls that produce exactly this kind of object tree.
// JSX you write
function SaveButton({ onSave }) {
return (
<button className="btn btn-primary" onClick={onSave}>
Save Changes
</button>
);
}
// What Babel compiles it to (modern JSX transform)
import { jsx as _jsx } from 'react/jsx-runtime';
function SaveButton({ onSave }) {
return _jsx('button', {
className: 'btn btn-primary',
onClick: onSave,
children: 'Save Changes'
});
}
The result of calling your component function is a tree of these plain objects — the Virtual DOM tree for that render.

| Characteristic | Real DOM | Virtual DOM |
|---|---|---|
| Lives in | Browser memory (C++ objects) | JavaScript heap (plain objects) |
| Creation cost | High | Very low |
| Read/write cost | High (triggers layout/paint) | Negligible |
| Size | Hundreds of properties per node | ~5 properties per node |
| Directly renderable | Yes | No |
| Diffable efficiently | Impractical | Yes |
Warning: The Virtual DOM is not inherently faster than the real DOM. It's a strategy to minimize the number of real DOM operations by doing cheap comparisons first. If you're making a single DOM change, going through the Virtual DOM adds overhead, not removes it. React's advantage appears at scale and complexity.
When your React application first loads, here is the sequence:
<App />)div, span, button, etc.)// Full initial render example
import { createRoot } from 'react-dom/client';
function UserCard({ name, role }) {
return (
<div className="user-card">
<h2>{name}</h2>
<span className="role-badge">{role}</span>
</div>
);
}
function App() {
return (
<main>
<UserCard name="Elena Vasquez" role="Engineering Lead" />
<UserCard name="Tariq Osei" role="Product Manager" />
</main>
);
}
const root = createRoot(document.getElementById('root'));
root.render(<App />);
On the first render, React has nothing to compare against. It simply builds the DOM from the Virtual DOM tree. The interesting part starts when state changes.
Let's answer the original question directly. When you call setState (or the state setter from useState), React schedules a re-render. During that re-render:
import { useState } from 'react';
function NotificationBanner() {
const [message, setMessage] = useState('Welcome back!');
const [isVisible, setIsVisible] = useState(true);
function handleDismiss() {
// This triggers the entire pipeline described above
setIsVisible(false);
}
if (!isVisible) return null;
return (
<div className="banner">
<p>{message}</p>
<button onClick={handleDismiss}>Dismiss</button>
</div>
);
}
When handleDismiss runs, React doesn't immediately remove the banner from the DOM. It schedules a re-render, re-executes NotificationBanner, gets null back, and then reconciles: the previous tree had a div.banner, the new tree has nothing. React removes the real DOM node.
Reconciliation is React's process of comparing the previous Virtual DOM tree to the new one and deciding what changed. The goal is to find the minimum set of operations to transform the old real DOM into one that matches the new Virtual DOM.
In theory, comparing two arbitrary trees has O(n³) complexity — for a tree of 1000 nodes, that's a billion comparisons. React makes two pragmatic assumptions that bring this to O(n):
div → section), React tears down the old subtree and builds a new one from scratch.key prop. This allows React to track which item in a list is which across renders.
React walks both trees simultaneously, node by node:
Same type, same position: React keeps the real DOM node and only updates changed attributes.
// Previous render
<input type="text" className="search-input" value="react" />
// New render — only 'value' changed
<input type="text" className="search-input" value="react hooks" />
// Real DOM operation: element.value = 'react hooks'
// The DOM node itself is reused — no teardown/rebuild
Different type, same position: React unmounts the old component, destroys its DOM, and mounts the new one.
// Previous render
<div className="container">
<TextEditor />
</div>
// New render — 'div' became 'section'
<section className="container">
<TextEditor />
</section>
// React destroys the entire old subtree including TextEditor's state
// This is a common source of accidental state loss
Warning: If you conditionally render different component types in the same position, React will unmount and remount them, destroying all local state. This is usually not what you want. Use the
keyprop or conditional logic inside a single component instead.
The diffing algorithm is the specific implementation of reconciliation for lists and children.
When React reconciles a list of children, it matches old children to new children by position — unless key props are present, in which case it matches by key.
// Without keys — matching by position
// Previous: [<li>Alice</li>, <li>Bob</li>, <li>Carol</li>]
// New: [<li>Zara</li>, <li>Alice</li>, <li>Bob</li>, <li>Carol</li>]
// React sees:
// Position 0: Alice → Zara (update text)
// Position 1: Bob → Alice (update text)
// Position 2: Carol → Bob (update text)
// Position 3: (new) → Carol (insert)
// Result: 3 updates + 1 insert = 4 operations
// With keys — matching by identity
// Previous: [<li key="alice">, <li key="bob">, <li key="carol">]
// New: [<li key="zara">, <li key="alice">, <li key="bob">, <li key="carol">]
// React sees:
// key="zara": new → insert before alice
// key="alice": exists → reuse, move position
// key="bob": exists → reuse, move position
// key="carol": exists → reuse, move position
// Result: 1 insert + repositioning = far fewer DOM mutations
// ❌ Using array index as key — breaks reconciliation when list reorders
function TaskList({ tasks }) {
return (
<ul>
{tasks.map((task, index) => (
<li key={index}>{task.title}</li> // index is unstable on reorder/insert
))}
</ul>
);
}
// ✅ Using stable, unique IDs
function TaskList({ tasks }) {
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li> // task.id is stable
))}
</ul>
);
}
Using array index as a key is safe only when the list is static (never reordered, never filtered, never prepended). In any other case, index keys cause React to incorrectly reuse DOM nodes and can corrupt controlled input state.
React's update process is split into two distinct phases with a clear boundary between them.
flowchart TD
A[State Change / setState] --> B[Render Phase]
B --> C[Re-execute Component Functions]
C --> D[Build New Virtual DOM Tree]
D --> E[Diff Against Previous Tree]
E --> F[Produce Effect List]
F --> G[Commit Phase]
G --> H[Apply DOM Mutations]
H --> I[Run useLayoutEffect]
I --> J[Browser Paint]
J --> K[Run useEffect]
The render phase is pure and side-effect free. React runs your component functions (and may do so multiple times in concurrent mode), produces the new Virtual DOM, runs the diffing algorithm, and builds a list of effects — the changes that need to be made to the real DOM. Nothing visible changes during this phase.
Because the render phase is pure, React can:
Note: This is exactly why React's documentation says you must not write to external state, kick off network requests, or cause observable side effects during the render of a component body. The render can be retried.
The commit phase is synchronous and cannot be interrupted. React takes the effect list produced by the render phase and applies it all at once:
useLayoutEffect cleanups and setups synchronouslyuseEffect cleanups and setups asynchronouslyThis separation is important: because mutations happen in one synchronous burst, the user never sees a half-updated UI.
Keys give React stable identity for list items across renders. They must be:
import { useState } from 'react';
const INITIAL_NOTIFICATIONS = [
{ id: 'n1', text: 'Deployment succeeded', type: 'success' },
{ id: 'n2', text: 'Memory usage high', type: 'warning' },
{ id: 'n3', text: 'New comment on PR #42', type: 'info' },
];
function NotificationList() {
const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS);
function dismissNotification(id) {
setNotifications(prev => prev.filter(n => n.id !== id));
}
function addUrgentAlert() {
const newAlert = { id: `n${Date.now()}`, text: 'Disk space critical!', type: 'error' };
// Prepend — index-based keys would break here
setNotifications(prev => [newAlert, ...prev]);
}
return (
<div className="notification-list">
<button onClick={addUrgentAlert}>Simulate Alert</button>
{notifications.map((notification) => (
// ✅ Stable ID from the data model
<div key={notification.id} className={`notification notification--${notification.type}`}>
<span>{notification.text}</span>
<button onClick={() => dismissNotification(notification.id)}>✕</button>
</div>
))}
</div>
);
}
When addUrgentAlert runs, React matches the three existing notifications by their id keys and simply inserts the new one at the top. With index keys, it would update the text of every existing node and create a new one at the bottom.
By default, when a parent component re-renders, all its children re-render too — even if their props didn't change. React still runs the diffing algorithm and usually decides nothing changed, so no real DOM updates happen. But the component function execution itself costs time.
React.memo is a higher-order component that memoizes the rendered output of a component. React skips re-executing the component function if its props haven't changed (shallow comparison).
import { memo, useState } from 'react';
// This component only re-renders if 'user' prop changes
const UserProfile = memo(function UserProfile({ user }) {
console.log('UserProfile rendered');
return (
<div className="profile-card">
<img src={user.avatarUrl} alt={user.name} />
<h3>{user.name}</h3>
<p>{user.bio}</p>
</div>
);
});
function Dashboard() {
const [tickCount, setTickCount] = useState(0);
const user = { name: 'Elena Vasquez', bio: 'Engineering Lead', avatarUrl: '/avatars/elena.jpg' };
return (
<div>
{/* Updating tickCount re-renders Dashboard but NOT UserProfile */}
<button onClick={() => setTickCount(c => c + 1)}>Tick: {tickCount}</button>
<UserProfile user={user} />
</div>
);
}
Warning:
React.memouses shallow comparison. If you pass a new object or array literal in props on every render (e.g.,user={{ name: 'Elena' }}), memo will always see a different prop and never skip the render. The object reference changes even if the content is identical.
useMemo memoizes a computed value. useCallback memoizes a function reference. Both are tools for stabilizing references so that React.memo and hooks with dependency arrays work correctly.
import { useState, useMemo, useCallback, memo } from 'react';
const ExpensiveChart = memo(function ExpensiveChart({ data, onDataPointClick }) {
// Imagine a complex D3 or Canvas rendering here
return <div>Chart with {data.length} points</div>;
});
function AnalyticsDashboard({ rawMetrics }) {
const [filter, setFilter] = useState('all');
const [selectedPoint, setSelectedPoint] = useState(null);
// useMemo: only recompute when rawMetrics or filter changes
const filteredData = useMemo(() => {
if (filter === 'all') return rawMetrics;
return rawMetrics.filter(m => m.category === filter);
}, [rawMetrics, filter]);
// useCallback: stable reference so ExpensiveChart doesn't re-render on every Dashboard render
const handleDataPointClick = useCallback((point) => {
setSelectedPoint(point);
}, []); // no dependencies — function doesn't close over changing state
return (
<div>
<select value={filter} onChange={e => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="errors">Errors</option>
<option value="latency">Latency</option>
</select>
<ExpensiveChart data={filteredData} onDataPointClick={handleDataPointClick} />
{selectedPoint && <PointDetail point={selectedPoint} />}
</div>
);
}
Tip: Don't reach for
useMemoanduseCallbackeverywhere. They add code complexity and their own overhead. Profile first. Optimize the components where re-renders are measurably expensive.
| Misconception | Reality |
|---|---|
| Virtual DOM eliminates real DOM updates | No. React still updates the real DOM — it just minimizes how many changes it makes |
| Virtual DOM is always faster than direct DOM manipulation | No. For a single targeted update, direct manipulation is faster. React wins at scale |
| Re-rendering means the DOM is rebuilt | No. Re-render means the component function is re-executed. DOM changes only happen in the commit phase if something actually changed |
| Reconciliation is perfectly optimal | No. The heuristic O(n) algorithm can miss some optimizations that a smarter but slower algorithm would find |
| Memoization is always the answer for slow React apps | No. Often the root cause is unnecessary state at the wrong level, not missing memo calls |
Here is the full pipeline from a state change to pixels on screen:

flowchart LR
A([State Change]) --> B[Component Re-executes]
B --> C[New Virtual DOM Tree]
C --> D{Reconciliation}
D --> E[Diffing Algorithm]
E --> F[Effect List]
F --> G[Commit Phase]
G --> H[DOM Mutations]
H --> I[useLayoutEffect]
I --> J([Browser Paint])
J --> K[useEffect]
Each step feeds directly into the next. The render phase (B through F) is pure and interruptible. The commit phase (G through I) is synchronous and uninterruptible. The browser paint happens between useLayoutEffect and useEffect, which is why useLayoutEffect is the right hook when you need to read layout information before the user sees the update.
| Scenario | Pitfall | Fix |
|---|---|---|
| Large, flat lists (1000+ items) | Even Virtual DOM diffing + render cost adds up | Virtualize with react-window or react-virtual |
| Deeply nested state updates | Every ancestor re-renders by default | Lift only necessary state; use context selectively |
| Inline object/array props | Breaks React.memo because reference always changes |
Extract to a variable outside JSX or use useMemo |
| Using array index as key | Corrupt state when list is mutated | Use stable data-model IDs |
| Effects with missing dependencies | Stale closure bugs | Follow the exhaustive-deps ESLint rule |
| Changing component type in same position | Destroys child state unexpectedly | Keep the type stable; change props instead |
Structure state to minimize the blast radius of updates. State low in the tree means fewer components re-render when it changes.
Profile before optimizing. React DevTools Profiler shows exactly which components re-rendered and why. Optimize what you can measure.
Use stable keys from your data model. Database IDs, slugs, UUIDs — anything that doesn't change when the list is reordered.
Understand what triggers a re-render: state changes, prop changes, context value changes, and parent re-renders. Knowing this lets you reason about cascades.
Keep component functions pure. Don't write to external variables, call APIs, or mutate props during render. Side effects belong in useEffect.
Don't optimize premature. A component that re-renders in 0.5ms doesn't need React.memo. Wrapping it adds cognitive overhead and memo comparison cost that might actually be slower.
Use useLayoutEffect for DOM measurements. If you need to read a layout property (like offsetHeight) and then synchronously update state before paint, useLayoutEffect is the correct tool.
React is best understood as a UI engine, not just a component library. When you call setState, you're not telling React what to do to the DOM — you're declaring what your UI should look like given new state, and letting the engine figure out the minimal path to get there.
The key takeaways:
useMemo everywhere.Your immediate next step: open React DevTools, enable the "Highlight updates when components render" setting, and interact with one of your existing apps. Watch which components light up and ask yourself why. That habit will teach you more about React's rendering model than any documentation could.
useEffect is and isn't the right tool.