Skip to main content

Command Palette

Search for a command to run...

Deep Dive into React Fiber: How React Actually Reconciles Your UI

Updated
8 min read
Deep Dive into React Fiber: How React Actually Reconciles Your UI
K
Full Stack Developer with 3+ years of experience building scalable SaaS web applications using the MERN stack (MongoDB, Express.js, React.js, Node.js). Skilled in developing responsive frontends with React/Next.js and TypeScript, designing RESTful APIs, and optimizing database performance. Experienced in CI/CD pipelines, Docker, and agile practices.

Frontend System Design Series — From virtual DOM diffing to commit mutations — a complete walkthrough of every phase and step React takes before a single pixel changes on screen.


Why This Topic Matters

You've probably said it in interviews: "React uses a virtual DOM and reconciles changes efficiently." But what actually happens between calling setState and the browser painting the updated UI?

The answer is React Fiber — the reconciliation engine powering React since v16. Understanding it explains why key props matter, why React.memo works, why hooks can't be called conditionally, and why Concurrent Mode exists.


The Big Picture: Two Phases

React's work is split into exactly two major phases:

┌────────────────────────────────────────┐
│        RENDER PHASE (async-safe)       │
│  Interruptible · No DOM mutations      │
│  beginWork → reconcileChildren → completeWork
└──────────────────┬─────────────────────┘
                   │ workInProgress tree ready
                   ▼
┌────────────────────────────────────────┐
│        COMMIT PHASE (synchronous)      │
│  Not interruptible · DOM changes here  │
│  Before Mutation → Mutation → Layout   │
└────────────────────────────────────────┘

Render Phase is pure computation — React figures out what changed without touching the real DOM. It's safely interruptible.

Commit Phase is irreversible — React applies all computed changes to the DOM in one synchronous pass. No interruptions allowed.


What Is a Fiber?

A fiber is a plain JavaScript object representing a unit of work. Every React element — component, DOM node, Fragment — has a corresponding fiber. It's React's internal representation of your component tree.

Key fields on a fiber:

  • tag — the type of fiber (FunctionComponent, ClassComponent, HostComponent like div)

  • pendingProps / memoizedProps — new vs last-committed props

  • memoizedState — the hooks linked list (for function components) or class state

  • child / sibling / return — tree structure pointers

  • stateNode — the actual DOM node (for host components)

  • flags — pending work: Placement, Update, Deletion

  • alternate — pointer to the other version of this fiber (current ↔ workInProgress)

React maintains two trees at all times:

  • current tree — what's rendered in the DOM right now

  • workInProgress tree — the tree being built during the render phase

This is the double buffering pattern. When commit completes, the workInProgress tree becomes the new current tree.


Trigger: How Reconciliation Starts

Reconciliation fires whenever React needs to update the UI — a setState call, useReducer dispatch, context value change, or a parent re-render. React schedules a render task and enters the render phase.


Phase 1: The Render Phase

The render phase is a depth-first traversal of the fiber tree. For each fiber, two functions fire: beginWork on the way down, completeWork on the way back up.

Step 1 — createWorkInProgress

Before any diffing, React clones the current fiber tree into a workInProgress copy. This is the scratch pad React edits freely — the current tree is never mutated directly. On the first render, fresh fibers are created. On subsequent renders, React reuses the previous workInProgress fibers.

Step 2 — beginWork

For every fiber, beginWork fires on the way down the tree. This is where your component function actually runs.

For a function component, beginWork does three things in sequence:

  1. Bailout check — if props and context haven't changed (think React.memo), skip re-rendering this fiber entirely

  2. Call your component function — runs renderWithHooks internally, which processes all hooks in order

  3. Call reconcileChildren — diffs the returned JSX against existing child fibers

It then returns the first child fiber so the traversal can descend into it.

Step 3 — renderWithHooks (inside beginWork)

This is where every hook in your component runs. React sets the appropriate dispatcher (mount vs update), calls your component function, and resets the dispatcher afterward.

Hooks are stored as a linked list on memoizedState. Each call to useState, useEffect, useRef, etc. appends a node to this list in order. This is exactly why the Rules of Hooks exist — if you call hooks conditionally, the order of nodes in the list changes between renders, and React loses track of which hook corresponds to which state.

Step 4 — reconcileChildren

After your component runs, React compares the new JSX elements against the existing fibers. No DOM mutations happen here — React only writes flags onto fibers.

The diff algorithm uses two key heuristics that keep it O(n):

Heuristic 1: Elements of different types produce different trees. A <div> becoming a <span> tears down the entire subtree — React doesn't try to reuse it.

Heuristic 2: The key prop signals stable identity across renders. For lists, React first matches elements by key. Without keys, it falls back to positional matching — which is why removing an item from the middle of a keyless list causes every subsequent item to re-render.

The output of reconciliation is flags on fibers:

Flag Meaning
Placement Insert this DOM node
Update Update props/attributes on this DOM node
Deletion Remove this DOM node
PassiveEffect A useEffect needs to run
LayoutEffect A useLayoutEffect needs to run

Step 5 — completeWork

When a fiber has no more children to process, completeWork fires on the way back up the tree.

For host components (div, input, span):

  • On mount: creates the actual DOM node and assembles the subtree in memory — off-screen, not attached to the document yet

  • On update: prepares a prop diff (what attributes/styles need to change)

  • Bubbles flags upward so parent fibers know their subtree has pending work

After completeWork reaches the root, the entire workInProgress tree is ready — all flags set, all DOM nodes prepared in memory.


Phase 2: The Commit Phase

The commit phase is synchronous and uninterruptible. It has three sub-phases.

Step 6 — commitBeforeMutationEffects

Runs before any DOM mutations. Its job is to snapshot the current state of the DOM before it changes — this is where getSnapshotBeforeUpdate is called on class components, letting them capture scroll positions or dimensions before the DOM updates. It also schedules useEffect cleanups and setups to run asynchronously after paint.

No DOM changes happen in this step.

Step 7 — commitMutationEffects

This is where the DOM actually changes. React walks the fiber tree and processes every flag:

  • Placement — inserts the prepared DOM node (from completeWork) into the correct position in the live document

  • Update — applies the prop diff to the existing DOM node (updates attributes, styles, event listeners)

  • Deletion — removes the DOM node from the document, and recursively fires componentWillUnmount and useLayoutEffect cleanups on all descendants

After this pass, the DOM reflects the new UI. React then flips the root's current pointer — the workInProgress tree is now the current tree.

Step 8 — commitLayoutEffects

Runs synchronously after DOM mutations but before the browser paints. This is where:

  • componentDidMount and componentDidUpdate fire on class components

  • useLayoutEffect setup functions run

  • New refs are attached

Because this runs before paint, useLayoutEffect is the right hook if you need to read layout (like getBoundingClientRect) and synchronously update state before the user sees anything. The tradeoff: it blocks the browser from painting until it completes.

Step 9 — Passive Effects (useEffect, async)

useEffect is deliberately not part of the synchronous commit. It's scheduled to run after the browser has painted, via a message channel task.

The order is always: cleanup from the previous render first, then the new setup. This ensures stale subscriptions or timers are torn down before new ones are created.


Full Flow at a Glance

setState / dispatch
        │
        ▼
  Schedule render
        │
  [RENDER PHASE]
  createWorkInProgress
        ↓
  beginWork (down)
    → renderWithHooks (run hooks)
    → reconcileChildren (diff, set flags)
        ↓
  completeWork (up)
    → build DOM nodes off-screen
    → bubble flags to root
        │
  [COMMIT PHASE]
  Step 6: commitBeforeMutationEffects (snapshot)
  Step 7: commitMutationEffects (DOM changes)
         root.current = workInProgress ✓
  Step 8: commitLayoutEffects (useLayoutEffect, refs)
        │
   Browser paints 🎨
        │
  Step 9: flushPassiveEffects (useEffect, async)

Why Fiber Made This Interruptible

In the old stack reconciler (React 15), the render phase was a single recursive call that ran to completion. Deep trees could block the main thread for hundreds of milliseconds, causing dropped frames.

Fiber replaced recursion with an iterative work loop. Each beginWork / completeWork call is a discrete unit of work. Between units, React can check if higher-priority work has arrived — user input, an animation frame — pause, handle it, and resume. The commit phase stays synchronous because you can't partially apply a UI update without showing users a broken intermediate state.

This interruptible render phase is the foundation of every Concurrent Mode feature: useTransition, useDeferredValue, Suspense with streaming — all depend on it.


Key Takeaways

  • The render phase only computes — no DOM mutations, safely interruptible

  • The commit phase applies — synchronous, three sub-passes, not interruptible

  • Hooks are a linked list ordered by call sequence — hence the Rules of Hooks

  • key props are fiber identity in lists — wrong or missing keys cause unnecessary re-mounts

  • useLayoutEffect runs before paint (synchronous), useEffect runs after paint (async)

  • Deleting a parent component automatically cleans up all descendant effects

Tags: #ReactJS #FrontendSystemDesign #WebPerformance #JavaScript #ReactFiber #FrontendEngineering