Episode 2 — React Frontend Architecture NextJS / 2.1 — Introduction to React
2.1.d — Real DOM vs Virtual DOM
In one sentence: The Virtual DOM is a lightweight JavaScript object representation of the real DOM that React uses to calculate the minimum number of actual DOM changes needed, making declarative UI updates performant.
Navigation: ← Single Page Applications · Next → Setting Up React With Vite
1. What Is the DOM
The Document Object Model (DOM) is a tree-structured representation of an HTML document that the browser creates after parsing HTML. It is the programming interface between JavaScript and the web page.
HTML to DOM
When the browser receives this HTML:
<!DOCTYPE html>
<html>
<head>
<title>My Page</title>
</head>
<body>
<div id="app">
<h1>Hello</h1>
<p class="intro">Welcome to my site.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
</div>
</body>
</html>
It constructs this tree:
document
|
<html>
/ \
<head> <body>
| |
<title> <div#app>
| / | \
"My Page" <h1> <p> <ul>
| | / \
"Hello" | <li> <li>
| | |
"Welcome..." "Item 1" "Item 2"
Every rectangle in this tree is a DOM node — an object in memory with properties and methods. JavaScript interacts with these objects:
// Each DOM node is a full JavaScript object
const h1 = document.querySelector('h1');
console.log(typeof h1); // "object"
console.log(h1.nodeName); // "H1"
console.log(h1.textContent); // "Hello"
console.log(h1.parentNode); // <div#app>
console.log(h1.childNodes); // NodeList [Text "Hello"]
console.log(h1.nextElementSibling); // <p.intro>
// DOM nodes have HUNDREDS of properties
console.log(Object.keys(h1).length); // 0 (enumerable)
// But there are hundreds of inherited properties from the prototype chain:
// HTMLHeadingElement -> HTMLElement -> Element -> Node -> EventTarget -> Object
DOM Nodes Are Expensive Objects
A single DOM element is far heavier than you might expect:
// How many properties does a simple <div> have?
const div = document.createElement('div');
let count = 0;
for (let key in div) {
count++;
}
console.log(count); // ~240+ properties!
// Some of them:
// align, title, lang, translate, dir, hidden, accessKey,
// draggable, spellcheck, autocapitalize, contentEditable,
// isContentEditable, inputMode, offsetParent, offsetTop,
// offsetLeft, offsetWidth, offsetHeight, style, innerText,
// outerText, onbeforexrselect, onabort, onblur, oncancel,
// oncanplay, oncanplaythrough, onchange, onclick, onclose,
// oncontextmenu, oncuechange, ondblclick, ondrag, ondragend,
// ... (200+ more)
Each DOM node also carries:
- A reference to its parent
- References to all children
- References to siblings
- Computed style information
- Layout geometry data
- Event handler references
- Attribute maps
- Shadow DOM references (if applicable)
Creating, modifying, or removing DOM nodes is a cross-boundary operation between JavaScript and the browser's rendering engine (written in C++). This boundary crossing has overhead.
2. How Browsers Parse HTML into the DOM
The browser does not just "read" HTML. It goes through a complex, multi-step pipeline.
The Parsing Pipeline
HTML bytes arrive from network
|
v
[1] BYTE STREAM DECODER
Convert bytes to characters (UTF-8, etc.)
<html><head>...
|
v
[2] TOKENIZER (Lexer)
Break character stream into tokens
StartTag:html, StartTag:head, StartTag:title,
Character:"My Page", EndTag:title, EndTag:head,
StartTag:body, StartTag:div, ...
|
v
[3] TREE BUILDER (Parser)
Build DOM tree from token stream
Handles implied tags (missing </p>, etc.)
Handles error correction (malformed HTML)
|
v
[4] DOM TREE
In-memory tree of node objects
JavaScript can now interact with it
CSS Processing (Parallel)
CSS arrives (from <link> or <style>)
|
v
[1] CSS TOKENIZER
Break CSS into tokens
selector, property, value, etc.
|
v
[2] CSS PARSER
Build CSSOM (CSS Object Model)
Rules, selectors, specificity calculations
|
v
[3] CSSOM TREE
Tree structure mirroring DOM,
with computed styles for each element
The Merge: Render Tree
DOM Tree CSSOM Tree
| |
+----------+-----------+
|
v
[RENDER TREE]
Only includes VISIBLE elements
(excludes display:none, <head>, etc.)
Each node has computed styles
|
v
[LAYOUT]
Calculate exact position and size
of every element on the page
(x, y, width, height)
|
v
[PAINT]
Convert layout to actual pixels
Fill pixels for text, backgrounds,
borders, shadows, images
|
v
[COMPOSITE]
Combine painted layers
Handle z-index, opacity, transforms
Send to GPU for display
|
v
Pixels on screen
3. The Critical Rendering Path
The Critical Rendering Path is the sequence of steps the browser takes to convert HTML, CSS, and JavaScript into pixels on the screen.
Critical Rendering Path:
HTML ─────> DOM
\
> Render Tree ─> Layout ─> Paint ─> Composite ─> Display
/
CSS ──────> CSSOM
JavaScript can modify both DOM and CSSOM at any point,
potentially forcing parts of this pipeline to re-run.
Layout (Reflow)
Layout calculates the geometry of every element: position, size, and how elements affect each other.
Layout calculation for a simple page:
<body> (viewport: 1200px wide)
|
<div style="width: 80%; padding: 20px;">
| Calculated: width = 960px, x = 120px (centered)
| Content area: 920px (960 - 40px padding)
|
+-- <h1> "Hello"
| Calculated: width = 920px, height = 40px (depends on font)
| Position: x = 140px, y = 20px
|
+-- <p> "Some text that wraps..."
| Calculated: width = 920px, height = depends on text wrap
| Position: x = 140px, y = 60px (below h1)
| Browser must calculate text wrap to determine height
|
+-- <ul>
| Position depends on <p> height, which depends on text wrap
+-- <li> "Item 1"
+-- <li> "Item 2"
Key point: layout is global. Changing the height of one element can shift every element below it. Changing the width of a parent affects all children.
What Triggers Layout (Reflow)
Any change to an element's geometry triggers layout recalculation:
// LAYOUT TRIGGERS (incomplete list):
// Changing dimensions
element.style.width = '100px';
element.style.height = '200px';
element.style.padding = '10px';
element.style.margin = '20px';
element.style.border = '1px solid black';
// Changing position
element.style.position = 'absolute';
element.style.top = '50px';
element.style.left = '100px';
element.style.float = 'left';
// Changing display
element.style.display = 'flex';
element.style.display = 'none';
// Changing fonts
element.style.fontSize = '20px';
element.style.fontFamily = 'Arial';
element.style.lineHeight = '1.5';
// Changing content
element.textContent = 'new text';
element.innerHTML = '<span>new HTML</span>';
element.appendChild(newChild);
element.removeChild(child);
// READING layout properties also forces reflow
// if there are pending style changes:
element.offsetWidth;
element.offsetHeight;
element.offsetTop;
element.offsetLeft;
element.clientWidth;
element.clientHeight;
element.scrollTop;
element.scrollHeight;
element.getBoundingClientRect();
window.getComputedStyle(element);
What Triggers Paint (Repaint)
Paint converts the layout into pixels. Changes that affect appearance but not geometry trigger repaint only (cheaper than reflow):
// PAINT-ONLY TRIGGERS (no layout recalculation):
element.style.color = 'red';
element.style.backgroundColor = 'blue';
element.style.visibility = 'hidden'; // Still takes up space
element.style.borderColor = 'green';
element.style.borderStyle = 'dashed';
element.style.outline = '1px solid red';
element.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)';
element.style.textDecoration = 'underline';
element.style.backgroundImage = 'url(image.png)';
Composite-Only Changes (Cheapest)
Some CSS changes only affect compositing — the cheapest operation:
// COMPOSITE-ONLY TRIGGERS (GPU-accelerated):
element.style.transform = 'translateX(100px)';
element.style.transform = 'scale(1.5)';
element.style.transform = 'rotate(45deg)';
element.style.opacity = '0.5';
element.style.willChange = 'transform';
// These are fast because the browser can:
// 1. Promote the element to its own GPU layer
// 2. Apply the transform/opacity on the GPU
// 3. No layout or paint needed
Cost Hierarchy
Operation cost (most expensive → cheapest):
Layout (Reflow) ████████████████████ ~10-100ms
Recalculates geometry for potentially ALL elements
Paint (Repaint) ████████████ ~1-10ms
Rasterizes affected area to pixels
Composite ████ ~0.1-1ms
GPU combines layers
4. Why DOM Manipulation Is Expensive
Now we can understand why manually updating the DOM for every state change is problematic.
The Layout Thrashing Problem (Revisited)
// Scenario: Update 100 items in a list
// TERRIBLE: Read-write-read-write pattern
const items = document.querySelectorAll('.item');
items.forEach(item => {
const height = item.offsetHeight; // READ → forces layout
item.style.height = (height * 2) + 'px'; // WRITE → invalidates layout
// Next iteration: READ again → forces layout AGAIN
});
// Timeline:
// Item 1: layout → read → invalidate →
// Item 2: layout → read → invalidate →
// Item 3: layout → read → invalidate →
// ...
// Item 100: layout → read → invalidate
// = 100 layout calculations!
// GOOD: Batch reads, then batch writes
const items = document.querySelectorAll('.item');
const heights = [];
// All reads (one layout calculation)
items.forEach(item => {
heights.push(item.offsetHeight);
});
// All writes (layout invalidated once, recalculated once)
items.forEach((item, i) => {
item.style.height = (heights[i] * 2) + 'px';
});
// = 2 layout calculations instead of 100
The Cascade Effect
Changing one element can affect many others:
Before change:
+------------------+
| Header (60px) |
+------------------+
| Content (varies) |
| |
| |
+------------------+
| Footer (40px) |
+------------------+
You change: header font-size from 16px to 24px
After change:
+------------------+
| Header (80px) | <- Height changed
| |
+------------------+
| Content (varies) | <- Position shifted down
| |
| |
+------------------+
| Footer (40px) | <- Position shifted down
+------------------+
ONE change → THREE elements recalculated
In a complex layout, this can cascade to hundreds of elements.
Real-World Benchmark
Benchmark: Updating 1000 rows in a table
Strategy: Replace innerHTML of entire table
Time: ~80ms (destroy all nodes, create all new nodes)
Strategy: Update textContent of each cell individually
Time: ~40ms (3000 individual DOM mutations)
Strategy: Calculate diff, update ONLY changed cells
Time: ~5ms (maybe 50 actual DOM mutations)
The diff + patch approach is 8-16x faster.
This is what the Virtual DOM does.
5. What Is the Virtual DOM
The Virtual DOM is a programming concept where a lightweight copy of the real DOM is kept in memory as plain JavaScript objects. React uses it as an intermediary step between your component code and actual DOM updates.
Virtual DOM Structure
A Virtual DOM node is just a JavaScript object describing an element:
// A React element (Virtual DOM node) is a plain object:
{
type: 'div',
props: {
className: 'card',
children: [
{
type: 'h1',
props: {
children: 'Hello World'
}
},
{
type: 'p',
props: {
className: 'description',
children: 'This is a card.'
}
},
{
type: 'button',
props: {
onClick: handleClick,
children: 'Click me'
}
}
]
}
}
Compare this to a real DOM node which has ~240 properties, references to the rendering engine, style calculation data, layout geometry, and more. The Virtual DOM object has maybe 3-5 properties. Creating it is trivially cheap.
Virtual DOM vs Real DOM: Object Comparison
Real DOM node (div):
~240 properties
Connected to rendering engine (C++)
Triggers layout/paint on modification
Lives in browser-managed memory
Cross-boundary access (JS ↔ C++)
Approximate memory: ~1-5 KB per node
Virtual DOM node (div):
~3-5 properties (type, props, key)
Plain JavaScript object
No rendering side effects on modification
Lives in JavaScript heap
Same-boundary access (JS ↔ JS)
Approximate memory: ~50-200 bytes per node
Creating 1000 Virtual DOM nodes: ~0.1ms
Creating 1000 Real DOM nodes: ~10ms (100x slower)
What React.createElement Returns
When you write JSX:
<div className="card">
<h1>{title}</h1>
<p>{description}</p>
</div>
Babel/SWC compiles it to:
React.createElement('div', { className: 'card' },
React.createElement('h1', null, title),
React.createElement('p', null, description)
);
React.createElement returns a plain object:
// Simplified — actual React elements have a few more internal fields
{
$$typeof: Symbol.for('react.element'), // Security: prevents XSS injection
type: 'div',
key: null,
ref: null,
props: {
className: 'card',
children: [
{
$$typeof: Symbol.for('react.element'),
type: 'h1',
key: null,
ref: null,
props: { children: 'My Title' }
},
{
$$typeof: Symbol.for('react.element'),
type: 'p',
key: null,
ref: null,
props: { children: 'My description text' }
}
]
}
}
This is the Virtual DOM. A tree of plain JavaScript objects.
6. How Virtual DOM Works: Render, Diff, Patch
React's update cycle has three phases:
State changes (setState called)
|
v
[1] RENDER
Call component functions
Produce NEW Virtual DOM tree
(plain JS objects, very fast)
|
v
[2] DIFF (Reconciliation)
Compare NEW tree with PREVIOUS tree
Find differences
Calculate minimum set of DOM operations
|
v
[3] PATCH (Commit)
Apply only the calculated DOM operations
to the real DOM
(minimum possible DOM mutations)
Step-by-Step Example
Starting state:
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('Alice');
return (
<div className="app">
<h1>Hello, {name}</h1>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Initial render (count = 0, name = 'Alice'):
Virtual DOM tree (v1):
{type: 'div', props: {className: 'app', children: [
{type: 'h1', props: {children: 'Hello, Alice'}},
{type: 'p', props: {children: 'Count: 0'}},
{type: 'button', props: {onClick: fn, children: 'Increment'}}
]}}
React creates real DOM elements matching this tree. This is the initial mount.
User clicks the button (count becomes 1):
Virtual DOM tree (v2):
{type: 'div', props: {className: 'app', children: [
{type: 'h1', props: {children: 'Hello, Alice'}}, // Same
{type: 'p', props: {children: 'Count: 1'}}, // CHANGED
{type: 'button', props: {onClick: fn, children: 'Increment'}} // Same
]}}
Diffing (compare v1 and v2):
Comparison:
div.app → Same type, same props → KEEP
h1 "Hello, Alice" → Same type, same text → KEEP
p "Count: 0" → Same type, text CHANGED to "Count: 1" → UPDATE
button "Increment" → Same type, same text → KEEP (onClick ref may differ)
Result: ONE DOM operation needed:
pElement.textContent = 'Count: 1';
Patching — React executes that single DOM operation. One text content change. The div, h1, and button are completely untouched.
Without Virtual DOM, a naive approach might:
// Naive: rebuild everything
appElement.innerHTML = `
<h1>Hello, Alice</h1>
<p>Count: 1</p>
<button>Increment</button>
`;
// This destroys ALL nodes and recreates them
// = 3 element creations, 3 text nodes, event listener re-attachment
// vs React's 1 text content change
7. The Diffing Algorithm
React's diffing algorithm (called "reconciliation") is what makes the Virtual DOM efficient. A naive tree diff algorithm is O(n^3) — for 1000 elements, that's 1 billion operations. React's algorithm is O(n) through two heuristics:
Heuristic 1: Different Types = Different Trees
If two elements have different types, React tears down the old tree and builds a new one from scratch.
// Before:
<div>
<Counter />
</div>
// After:
<span>
<Counter />
</span>
// React's decision:
// div -> span (different types!)
// Destroy entire <div> subtree (including <Counter /> and all its state)
// Create new <span> subtree with new <Counter /> (fresh state)
// Before:
<Counter /> (type: Counter component)
// After:
<Timer /> (type: Timer component)
// Different component types → destroy Counter, create Timer
// Even if Counter and Timer render similar HTML
Heuristic 2: Keys Identify Stable Elements
When comparing lists, React uses the key prop to match elements between renders:
// Before:
<ul>
<li key="a">Alice</li>
<li key="b">Bob</li>
<li key="c">Charlie</li>
</ul>
// After (Alice removed):
<ul>
<li key="b">Bob</li>
<li key="c">Charlie</li>
</ul>
// With keys, React knows:
// key="a" is gone → remove that <li>
// key="b" still exists → keep it (maybe reposition)
// key="c" still exists → keep it (maybe reposition)
// = 1 removal
// Without keys, React compares by position:
// Position 0: "Alice" → "Bob" (text changed → update)
// Position 1: "Bob" → "Charlie" (text changed → update)
// Position 2: "Charlie" → (gone) (remove)
// = 2 updates + 1 removal (more work!)
The Full Diffing Process
Diffing algorithm (simplified):
compareNodes(oldNode, newNode):
IF oldNode is null:
→ CREATE new DOM element (mount)
IF newNode is null:
→ REMOVE old DOM element (unmount)
IF oldNode.type !== newNode.type:
→ REMOVE old subtree entirely
→ CREATE new subtree from scratch
IF oldNode.type === newNode.type:
→ KEEP the DOM element
→ UPDATE changed props/attributes only
→ RECURSE into children:
IF children have keys:
→ Match old and new children by key
→ Reorder, add, remove as needed
IF children have NO keys:
→ Compare by index position
→ Update each position individually
Why Keys Matter: A Visual Example
Scenario: Insert item at the beginning of a list
Before: After:
[B] [C] [D] [A] [B] [C] [D]
WITHOUT keys (comparison by index):
Position 0: B → A (update text)
Position 1: C → B (update text)
Position 2: D → C (update text)
Position 3: _ → D (create new)
= 3 updates + 1 creation = 4 operations
EVERY existing element's content changes!
WITH keys:
key="B": exists before and after → keep in place
key="C": exists before and after → keep in place
key="D": exists before and after → keep in place
key="A": new → create and insert at position 0
= 1 creation + 1 insertion = 2 operations
Existing elements are UNTOUCHED
What Makes a Good Key
// GOOD keys: stable, unique identifiers
{users.map(user => (
<UserCard key={user.id} user={user} /> // Database ID — perfect
))}
{messages.map(msg => (
<Message key={msg.uuid} message={msg} /> // UUID — perfect
))}
// BAD key: array index
{items.map((item, index) => (
<Item key={index} item={item} />
// If items are reordered, index doesn't follow the item.
// Item "Alice" was at index 0, now at index 2.
// React thinks index 0's CONTENT changed, not its position.
// This causes unnecessary re-renders and can break component state.
))}
// BAD key: random value
{items.map(item => (
<Item key={Math.random()} item={item} />
// New key every render → React destroys and recreates EVERY element
// Defeats the entire purpose of the diffing algorithm
))}
// BAD key: non-unique
{items.map(item => (
<Item key={item.category} item={item} />
// If multiple items share a category, keys aren't unique
// React gets confused about which element is which
))}
8. Reconciliation: The Full Process
Reconciliation is React's process for determining which parts of the Virtual DOM tree changed and how to efficiently update the real DOM.
Phase 1: Triggering a Re-render
A re-render is triggered by:
Trigger sources:
1. setState() / useState setter — component state changed
2. Parent re-rendered — component receives new props
3. Context value changed — useContext triggers consumers
4. forceUpdate() — explicit (class components, rare)
Phase 2: The Render Phase (Pure)
Render phase:
React calls your component function
Your function returns JSX (compiled to createElement calls)
createElement calls produce Virtual DOM objects
React builds a complete new Virtual DOM tree
IMPORTANT: This phase is PURE
- No DOM mutations
- No side effects
- Can be interrupted (in Concurrent React)
- Can be called multiple times
- Must produce the same output for the same input
Phase 3: The Commit Phase (Side Effects)
Commit phase:
React applies the calculated DOM changes
This is synchronous and cannot be interrupted
Order of operations:
1. Apply DOM mutations (insertions, updates, deletions)
2. Run useLayoutEffect callbacks (synchronously, before paint)
3. Browser paints the screen
4. Run useEffect callbacks (asynchronously, after paint)
The Work Loop
React's reconciliation work loop (simplified):
workInProgress = rootFiber;
while (workInProgress !== null) {
// Process current fiber node
beginWork(workInProgress);
if (workInProgress.child) {
// Go deeper (process children first — depth-first)
workInProgress = workInProgress.child;
} else {
// No children — complete this node
completeWork(workInProgress);
if (workInProgress.sibling) {
// Process sibling
workInProgress = workInProgress.sibling;
} else {
// Go back up to parent
workInProgress = workInProgress.return;
}
}
}
9. Batch Updates
React batches multiple state updates into a single re-render for efficiency.
Before React 18
// React 17 and earlier: batching only in React event handlers
function handleClick() {
setCount(1); // Does NOT re-render yet
setName('Alice'); // Does NOT re-render yet
setAge(30); // Does NOT re-render yet
// React batches all three → ONE re-render at the end
}
// But in async code, NO batching:
setTimeout(() => {
setCount(1); // Re-renders immediately!
setName('Alice'); // Re-renders AGAIN!
setAge(30); // Re-renders a THIRD time!
// = 3 re-renders instead of 1
}, 100);
fetch('/api/data').then(data => {
setItems(data.items); // Re-render #1
setLoading(false); // Re-render #2
// = 2 re-renders
});
React 18: Automatic Batching
// React 18+: batching EVERYWHERE
// In event handlers (same as before)
function handleClick() {
setCount(1);
setName('Alice');
setAge(30);
// → ONE re-render
}
// In setTimeout (NEW: now batched!)
setTimeout(() => {
setCount(1);
setName('Alice');
setAge(30);
// → ONE re-render (was 3 in React 17)
}, 100);
// In Promises (NEW: now batched!)
fetch('/api/data').then(data => {
setItems(data.items);
setLoading(false);
// → ONE re-render (was 2 in React 17)
});
// In native event handlers (NEW: now batched!)
document.getElementById('btn').addEventListener('click', () => {
setCount(1);
setName('Alice');
// → ONE re-render
});
Why Batching Matters
Without batching (3 state updates):
setState(count: 1)
→ render Virtual DOM
→ diff
→ patch DOM (reflow + repaint)
setState(name: 'Alice')
→ render Virtual DOM
→ diff
→ patch DOM (reflow + repaint)
setState(age: 30)
→ render Virtual DOM
→ diff
→ patch DOM (reflow + repaint)
= 3 renders, 3 diffs, 3 DOM patches, 3 reflows
With batching:
setState(count: 1) → queued
setState(name: 'Alice') → queued
setState(age: 30) → queued
→ ONE render with all three state updates applied
→ ONE diff
→ ONE DOM patch
→ ONE reflow
= 3x less work
10. React Fiber Architecture
React Fiber is the reimplementation of React's core algorithm, introduced in React 16 (2017). It replaced the old "Stack Reconciler" with a more flexible architecture.
The Problem with the Stack Reconciler
The original React reconciler worked like a recursive function call:
Old reconciler (Stack):
reconcile(App)
reconcile(Header)
reconcile(Logo)
reconcile(NavBar)
reconcile(NavLink)
reconcile(NavLink)
reconcile(NavLink)
reconcile(Content)
reconcile(Article)
reconcile(Paragraph)
reconcile(Paragraph)
reconcile(Image)
reconcile(Footer)
This is one synchronous, recursive function call.
Once started, it CANNOT be interrupted.
For a tree with 10,000 nodes, this could take 100ms+.
During that time, the browser cannot:
- Respond to user input
- Run animations
- Handle resize events
Result: UI feels frozen/janky
The Fiber Solution
Fiber turns the tree reconciliation into an incremental process using a linked-list data structure:
Fiber node structure (simplified):
{
type: 'div', // Element type
stateNode: domElement, // Reference to real DOM node
// Tree structure (linked list, not recursive calls)
child: fiberNode, // First child
sibling: fiberNode, // Next sibling
return: fiberNode, // Parent
// Work tracking
pendingProps: {}, // New props to apply
memoizedProps: {}, // Props from last render
memoizedState: {}, // State from last render
// Effects
flags: Update | Placement, // What DOM operations needed
// Priority
lanes: SyncLane | DefaultLane, // How urgent is this update?
}
Fiber traversal (interruptible):
Process App fiber
|
Can we continue? (check if browser needs to handle input)
YES → Process Header fiber
|
Can we continue?
YES → Process Logo fiber
|
Can we continue?
NO → YIELD to browser (handle user input, animation frame)
|
Browser done → RESUME
|
Process NavBar fiber
...
Time Slicing
Fiber enables React to split rendering work into chunks and spread it across multiple animation frames:
Without Fiber (synchronous):
Frame 1: [=====REACT RECONCILIATION (100ms)=====][paint]
Frame 2: [idle.....................................][paint]
Frame 3: [idle.....................................][paint]
User clicks during Frame 1 → NOT processed until reconciliation finishes
= 100ms input delay (very noticeable)
With Fiber (time-sliced):
Frame 1: [=React 5ms=][input][animation][paint]
Frame 2: [=React 5ms=][input][animation][paint]
Frame 3: [=React 5ms=][input][animation][paint]
...
Frame 20: [React done][input][animation][paint]
User clicks during any frame → processed in the same frame
= <16ms input delay (imperceptible)
Priority Lanes
React Fiber assigns priorities to updates:
Priority levels (React 18):
Immediate (SyncLane):
- User typing in an input
- Clicking a button
Must be processed THIS frame
User-blocking (InputContinuousLane):
- Hover events
- Drag events
Should be processed within a few frames
Normal (DefaultLane):
- Data fetching results
- setState from network callbacks
Can wait a few frames
Low (IdleLane):
- Prefetching
- Background work
Process when nothing else is happening
Offscreen:
- Pre-rendering hidden content
- Lowest priority
// Practical example: Search with urgent + non-urgent updates
function SearchPage() {
const [input, setInput] = useState('');
const [results, setResults] = useState([]);
const handleChange = (e) => {
// Urgent: update the input field immediately (user expects instant feedback)
setInput(e.target.value);
// Non-urgent: filter results can wait a frame or two
startTransition(() => {
const filtered = allItems.filter(item =>
item.name.includes(e.target.value)
);
setResults(filtered);
});
};
return (
<>
<input value={input} onChange={handleChange} />
{/* Input updates instantly; results update when React has time */}
<ResultsList results={results} />
</>
);
}
11. Common Misconceptions About the Virtual DOM
Misconception 1: "The Virtual DOM is faster than the Real DOM"
Reality: The Virtual DOM is NOT faster than direct, optimal DOM manipulation. It is faster than naive DOM manipulation.
Speed comparison:
Optimal hand-written DOM updates:
// You manually calculate the minimum DOM operations
// This is the theoretical maximum performance
element.textContent = 'new text'; // ~0.01ms
Virtual DOM (React):
// React calculates the minimum DOM operations FOR you
// Slight overhead for the diffing step
// ~0.1ms for diff + ~0.01ms for DOM update = ~0.11ms
Naive DOM updates (innerHTML, rebuilding):
// Replace everything
container.innerHTML = renderEverything(); // ~5ms
Result:
Hand-optimized > Virtual DOM > Naive rebuild
BUT: Hand-optimization doesn't scale.
No human can optimally diff 10,000 elements across 50 state changes.
The Virtual DOM gives you "close to optimal" for FREE.
Misconception 2: "React re-renders the entire page on every update"
Reality: React re-renders the Virtual DOM tree for the affected component and its children. But the Real DOM is only updated where actual changes occurred.
State change in Component B:
App App
| |
+-- Header +-- Header (not re-rendered)
| |
+-- Content +-- Content (not re-rendered)
| | | |
| +-- ArticleA | +-- ArticleA (not re-rendered)
| | | |
| +-- ArticleB * | +-- ArticleB (RE-RENDERED in VDOM)
| | | |
| +-- Para1 | +-- Para1 (RE-RENDERED in VDOM)
| +-- Para2 | +-- Para2 (RE-RENDERED in VDOM)
| |
+-- Footer +-- Footer (not re-rendered)
"Re-rendered in VDOM" = function called, new VDOM objects created
But if the output is the same, NO real DOM changes happen.
Misconception 3: "You should avoid re-renders at all costs"
Reality: Re-renders (calling the component function) are cheap. It's unnecessary DOM mutations that are expensive. React's diffing ensures that re-renders only cause DOM mutations when something actually changed.
// This component re-renders when parent re-renders
// But if props haven't changed, React's diff finds ZERO DOM changes
// Cost: ~0.01ms to call function + ~0.05ms to diff = ~0.06ms
// This is NOT a performance problem.
function StaticHeader({ title }) {
return (
<header>
<h1>{title}</h1>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</header>
);
}
Only optimize re-renders when profiling shows they're causing actual performance problems (hundreds of components re-rendering, expensive computations in render, etc.).
Misconception 4: "Virtual DOM is unique to React"
Reality: Vue.js also uses a Virtual DOM. Preact uses one. Inferno uses one. The concept predates React (it was inspired by game development's double buffering technique). However, React popularized it for web UI development.
Other frameworks take different approaches entirely:
- Svelte: No Virtual DOM — compiles components to direct DOM updates at build time
- Solid.js: Fine-grained reactivity — updates only the exact DOM nodes that depend on changed state
- Angular (Ivy): Incremental DOM — generates DOM instructions at compile time
12. React's Rendering Phases: Render vs Commit
Understanding the two-phase process is critical for writing correct React code.
Render Phase
RENDER PHASE:
What happens:
- React calls your component functions
- Component functions return JSX (Virtual DOM objects)
- React builds the new Virtual DOM tree
- React diffs new tree against previous tree
- React calculates the list of DOM changes needed
Properties:
- PURE: no side effects allowed
- INTERRUPTIBLE: React can pause and resume (Fiber)
- May be called MULTIPLE times for the same update (Concurrent Mode)
- Should not read from or write to the DOM
- Should not set timers, make network requests, etc.
What belongs here:
- Computing derived values
- Transforming data
- Returning JSX
What does NOT belong here:
- DOM manipulation
- API calls
- Subscriptions
- Logging (may fire multiple times!)
Commit Phase
COMMIT PHASE:
What happens:
- React applies DOM mutations (insertions, updates, deletions)
- React updates refs (ref.current = domElement)
- React runs layout effects (useLayoutEffect)
- Browser paints
- React runs effects (useEffect)
Properties:
- SYNCHRONOUS: cannot be interrupted
- Runs ONCE per update
- Side effects are allowed
Timeline:
1. DOM mutations applied
2. useLayoutEffect runs (synchronous, before paint)
→ Safe to read DOM layout
→ Safe to synchronously update DOM
3. Browser paints the screen
4. useEffect runs (asynchronous, after paint)
→ API calls, subscriptions, timers
→ Most side effects go here
Practical Example
function SearchResults({ query }) {
// RENDER PHASE: These run during render (pure computation)
const results = useMemo(() => {
return allData.filter(item => item.name.includes(query));
}, [query]);
const count = results.length;
// This REF setup happens in render, but ref.current is set in commit
const listRef = useRef(null);
// COMMIT PHASE: useLayoutEffect (before paint)
useLayoutEffect(() => {
// Safe to read layout — the DOM is updated but not yet painted
if (listRef.current) {
const height = listRef.current.scrollHeight;
// Adjust container height synchronously before paint
// User never sees the intermediate state
listRef.current.style.maxHeight = Math.min(height, 400) + 'px';
}
}, [results]);
// COMMIT PHASE: useEffect (after paint)
useEffect(() => {
// Log search analytics (side effect, not urgent)
analytics.track('search', { query, resultCount: count });
}, [query, count]);
// RENDER PHASE: Return JSX (pure)
return (
<div>
<p>{count} results for "{query}"</p>
<ul ref={listRef}>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
13. The Double Buffering Analogy
React's Virtual DOM works similarly to double buffering in video games and graphics programming.
Video game rendering (double buffer):
Buffer A (currently displayed):
+---------------------------+
| Player at position (5,3) |
| Enemy at position (8,7) |
| Score: 100 |
+---------------------------+
Buffer B (being prepared, not visible):
+---------------------------+
| Player at position (6,3) | ← player moved right
| Enemy at position (7,7) | ← enemy moved left
| Score: 150 | ← score increased
+---------------------------+
When Buffer B is ready:
SWAP buffers atomically
Buffer B becomes the displayed buffer
Buffer A becomes the work buffer
The user NEVER sees a half-updated frame.
React's Virtual DOM (double buffer):
Real DOM (currently displayed):
+---------------------------+
| <h1>Count: 0</h1> |
| <p>Hello, Alice</p> |
+---------------------------+
Virtual DOM (being prepared, not visible):
+---------------------------+
| <h1>Count: 1</h1> | ← count changed
| <p>Hello, Alice</p> | ← unchanged
+---------------------------+
When Virtual DOM is ready:
DIFF against previous Virtual DOM
Apply ONLY the changes to Real DOM:
h1.textContent = 'Count: 1';
The user NEVER sees a half-updated DOM.
(Count shows 1 but name still updating — impossible.)
This is why React feels smooth: all changes for a single state update are applied atomically to the DOM. You never see an intermediate state where some parts are updated and others aren't.
14. Key Takeaways
-
The DOM is a tree of expensive objects. Each DOM node has ~240 properties and is managed by the browser's rendering engine in C++. Creating, modifying, and removing nodes involves crossing the JavaScript-to-native boundary.
-
DOM changes trigger the rendering pipeline. A change to an element's size triggers layout recalculation for potentially the entire page, followed by paint and compositing. This is why DOM manipulation is "expensive."
-
The Virtual DOM is a lightweight JavaScript copy. Virtual DOM nodes are plain objects with 3-5 properties. Creating and comparing them is orders of magnitude cheaper than real DOM operations.
-
React's three-step process: Render, Diff, Patch. Component functions produce a new Virtual DOM tree (render), React compares it with the previous tree (diff/reconciliation), then applies the minimum DOM changes (patch/commit).
-
The diffing algorithm is O(n) due to two heuristics: (a) different element types produce different trees, and (b)
keyprops identify stable elements across renders. -
Keys are critical for list performance. Without proper keys, React falls back to index-based comparison, which causes unnecessary DOM operations when items are reordered, inserted, or removed.
-
React batches state updates. Multiple
setStatecalls are combined into a single re-render, minimizing DOM mutations and reflows. -
React Fiber enables interruptible rendering. Large updates can be split across multiple animation frames, preventing the UI from freezing during complex reconciliation.
-
The Virtual DOM is not "faster than the DOM." It is a mechanism that makes the declarative programming model performant. The real innovation is that you write declarative code, and React handles optimal DOM updates automatically.
-
Render phase is pure, commit phase has side effects. Understanding this split is essential for knowing where to put different kinds of code (computation vs DOM interaction vs API calls).
Explain-It Challenge
-
Draw the complete pipeline from
setState({count: 5})to pixels on the screen. Include: render phase, Virtual DOM creation, diffing, commit phase, layout, paint, and composite. Label which parts are JavaScript and which are browser engine. -
Given a list of 1000 items where one item in the middle is removed, explain step by step what React does with keys vs without keys. How many DOM operations does each approach require?
-
Someone argues: "Svelte is better because it has no Virtual DOM overhead." Explain the tradeoff Svelte makes (compile-time analysis vs runtime diffing), and describe a scenario where React's runtime approach might actually be advantageous.
Navigation: ← Single Page Applications · Next → Setting Up React With Vite