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

  1. 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.

  2. 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."

  3. 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.

  4. 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).

  5. The diffing algorithm is O(n) due to two heuristics: (a) different element types produce different trees, and (b) key props identify stable elements across renders.

  6. 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.

  7. React batches state updates. Multiple setState calls are combined into a single re-render, minimizing DOM mutations and reflows.

  8. React Fiber enables interruptible rendering. Large updates can be split across multiple animation frames, preventing the UI from freezing during complex reconciliation.

  9. 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.

  10. 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

  1. 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.

  2. 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?

  3. 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