DEV Community

Vishal Desai
Vishal Desai

Posted on

React DOM Internals

React-DOM-ThumbnailBefore diving in this is a continuation of the React 19 Internals series. If you haven’t read the previous part, I’d recommend starting there. That said, if you already understand till the React Elements, you’re good to go — feel free to jump straight in.

Let’s start with the foundation: what does React DOM actually do?

At its core, React DOM acts as the bridge between React Elements and the Browser DOM.

The Entry Point

Before we explore how a React Element gets converted into a Fiber node, we need to understand how React DOM even knows your app exists — the entry point.

If you open any standard React project, you’ll find an index.html that looks like this:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Notice the <div id="root"> this is the single DOM node React will take ownership of. Everything React renders will live inside it.

The bridge between this bare div and your component tree is established in main.tsx:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
Enter fullscreen mode Exit fullscreen mode

Let’s break down what’s actually happening here:

createRoot(container) — This creates a ReactDOMRoot object and attaches it to the DOM node you pass in. Under the hood, it initializes a Fiber root the internal data structure React uses to track your entire component tree.
.render() — This schedules the initial render. Internally, it calls scheduleUpdateOnFiber, which kicks off the reconciliation process and eventually walks your component tree, creating Fiber nodes along the way.
So the flow looks like this:

index.html (div#root)
    ↓
main.tsx → createRoot() → FiberRoot created
    ↓
.render(<App />) → scheduleUpdateOnFiber()
    ↓
Reconciler begins building the Fiber tree
Enter fullscreen mode Exit fullscreen mode

Scheduler & Lanes: Prioritizing Work

React doesn’t process all updates equally some updates need to happen immediately (like a button click response), while others can wait (like a background data fetch). This prioritization is managed through two systems working together: Lanes and the Scheduler.

React Fiber

Now that the scheduler has picked up the work and knows when to run it, React needs a way to actually represent and track component tree internally. This is where Fiber comes in.

What Is a Fiber Node?

A Fiber node is React’s internal representation of a single unit of work. Every React element in your tree a

,a component, the root itself — gets a corresponding Fiber node created for it.

Think of the Fiber tree as a shadow copy of your React element tree, but enriched with all the internal state, scheduling info, and DOM references React needs to do its job.

How Fiber Node Are Created?

When React processes a React element, it calls:

createFiber(tag, pendingProps, key, mode)

This initialized a FiberNode a plain object with a well-defined set of fields. The Fiber tree is not built using arrays or a traditional parent→children structure. Instead, it's a linked list, using three pointers: child, sibling, and return.

Anatomy of a Fiber Node

Here’s what a real Fiber node looks like in memory:

const fiberNode = {
  // --- Identity ---
  tag: 5,           // HostComponent (5 = DOM element like <div>, <p>, etc.)
  type: 'div',      // the actual element type
  key: null,        // React key, used for reconciliation

  // --- Tree pointers (linked list, NOT an array) ---
  return: parentFiber,   // pointer to parent fiber
  child: childFiber,     // pointer to first child
  sibling: null,         // pointer to next sibling

// --- Props & State ---
  pendingProps: { className: 'app', children: 'Hello' },  // props coming in
  memoizedProps: { className: 'app', children: 'Hello' }, // props from last render
  memoizedState: null,   // for function components: the hook linked list
  updateQueue: null,     // pending state updates

// --- Side-effect flags (bitmask) ---
  flags: 0b000000000100,  // e.g. Update = 4 (what needs to happen to the DOM)
  subtreeFlags: 0,        // aggregated flags from all descendants
  deletions: null,        // child fibers scheduled for removal

// --- Scheduling ---
  lanes: 1,        // SyncLane = 1 (priority of pending work on this fiber)
  childLanes: 0,   // aggregated lanes from all descendants

// --- Output ---
  stateNode: document.querySelector('div.app'), // the real DOM node
  alternate: workInProgressFiber,               // the double-buffer twin
  mode: 0,                                      // ConcurrentMode flag
};

Let’s walk through each group of fields.

tag — What Kind of Fiber Is This?

The tag field tells React what type of node it's dealing with. Each type has a constant value:

|        Tag       | Value |           Meaning                   |           
|------------------|-------|-------------------------------------|
| FunctionComponent|   0   |   A Function Component              |       
| ClassComponent   |   1   |   A Class Component                 |         
| HostRoot         |   3   |The Root of the tree(CreateRoot)     |
| HostComponent    |   5   |A native dom elements(`<div>,<span>`)|
| Host Text        |   6   |    A plan text node                 |

This is the first thing React checks when it begins processing a fiber — the tag determines what work needs to be done.

Tree Pointers — The Linked List Structure

The Fiber tree doesn’t use a children: [] array like React elements do. Instead, each fiber has three pointers:

App (return ←)
       │
       ▼ (child)
      Header → Main → Footer   (siblings →)
       │
       ▼ (child)
      h1 → nav

child — points to the first child fiber
sibling — points to the next sibling fiber
return — points back up to the parent fiber

This flat linked-list structure is what makes React’s work loop interruptible. Rather than recursing down a tree (which blocks the call stack), React can walk this list iteratively, pausing and resuming between fibers.

Props Fields

React keeps two copies of props on each fiber:

pendingProps — the props arriving from the new render
memoizedProps — the props that were used in the last completed render

During reconciliation, React compares these two. If they’re identical, React can bail out of re-rendering that subtree entirely — this is the foundation of how React.memo and shouldComponentUpdate work at the fiber level.

For function components, memoizedState doesn't store a single state value — it stores the head of a linked list of hooks. Every useState, useEffect, and useRef call appends a node to this list, in the order they were called. This is exactly why hooks cannot be called conditionally — the order must be consistent between renders.

Flags — What Needs to Change?

flags: 0b000000000100  // Update = 4

flags is a bitmask that records what side effects need to be applied to the DOM for this fiber once the render phase is complete. Common flags include:

|  Flag       |Value|       Meaning                              |
|-------------|-----|--------------------------------------------|
| Placement   |  2  |Insert this node to the DOM          
| Update      |  4  |Update props/attributes on existing DOM node|
| Deletion    |  8  | Remove this node from the DOM              |
|ChildDeletion|  16 | A child of this node needs to removed      |

subtreeFlags is an aggregation of all flags values from every descendant fiber. This lets React skip entire subtrees during the commit phase if subtreeFlags === 0 — no work needed below this point.

Lanes on the Fiber

lanes: 1,       // SyncLane
childLanes: 0,

Not just the root, but individual fibers carry lane information. lanes tells React which priority level has pending work on this specific fiber. childLanes is an OR of all lanes in the subtree below — again used to skip entire branches if no pending work exists at the required priority.

stateNode — The Real DOM Reference

For host components (tag: 5), stateNode holds a direct reference to the actual DOM node. This is how React is able to efficiently apply DOM mutations during the commit phase — it doesn't have to query the DOM; it already has the reference sitting right on the fiber.

For class components, stateNode holds the component instance (the object with this.setState). For the root fiber (HostRoot), it holds a reference to the FiberRootNode.

The Double Buffer — alternate

alternate: workInProgressFiber
React always maintains two fiber trees:

The current tree — what’s currently rendered on screen
The work-in-progress (WIP) tree — the tree being built for the next render
Each fiber in the current tree has an alternate pointer to its counterpart in the WIP tree, and vice versa. When a render completes, React simply flips which tree is "current" by updating the root pointer the old current tree becomes the next WIP tree, ready to be reused.

This double-buffering is what allows React to abandon a half-finished render (in concurrent mode) without corrupting what’s on screen — the current tree is never touched until work is fully complete.

Putting It Together

createRoot()
    ↓
HostRoot Fiber created (tag: 3)
    ↓
render(<App />) triggers work
    ↓
App Fiber created (tag: 0, FunctionComponent)
    ↓ child
div Fiber created (tag: 5, HostComponent)  ←── stateNode = real <div>
    ↓ child           → sibling
 h1 Fiber           p Fiber

Each node in this tree carries everything React needs — its place in the tree, its props, its priority, its DOM reference, and its twin in the alternate tree.

In the next part, we’ll look at how React actually walks this tree during the render phase — the beginWork and completeWork cycle.

The Render Phase: beginWork & completeWork
Now that we have a Fiber tree built out, React needs to walk it. The render phase is React’s process of figuring out what changed it doesn’t touch the DOM yet. All DOM mutations happen later, in the commit phase. The render phase is purely about building up a picture of what the UI should look like.

This walk is driven by two functions: beginWork and completeWork.

The Work Loop

Before diving into either function, let’s understand the loop that drives them. At the heart of React’s renderer is workLoopSync (for synchronous renders) or workLoopConcurrent (for concurrent renders):

// Synchronous — runs to completion, never yields
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}
// Concurrent — yields to the browser if time is up
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

workInProgress is a pointer to the fiber currently being processed. performUnitOfWork is what actually moves it forward:

function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate; // the current (on-screen) fiber
  const next = beginWork(current, unitOfWork, renderLanes);

  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    // No children — this fiber is done
    completeUnitOfWork(unitOfWork);
   } else {
    // Move down into the child
    workInProgress = next;
    }
  }

The logic is straightforward:

Call beginWork on the current fiber. If it returns a child, move down into it.
If there are no more children (next === null), call completeUnitOfWork to finish this fiber and move sideways or upward.
This gives the traversal a very specific shape — and understanding that shape is the key to understanding the whole render phase.

The Traversal Order

React walks the fiber tree in a depth-first order, but the direction alternates:

beginWork fires as React moves down into a fiber (entering it)
completeWork fires as React moves up/sideways out of a fiber (exiting it)
Given this tree:

      App
       │
      div
     /   \
   h1     p

The traversal order looks like this:

beginWork(App)
beginWork(div)
beginWork(h1)
completeWork(h1)
beginWork(p)
completeWork(p)
completeWork(div)
completeWork(App)
Every fiber is entered once and exited once. beginWork goes down, completeWork comes back up. Keep this mental model — it'll make everything below much clearer.

beginWork — Entering a Fiber

function beginWork(
  current: Fiber | null,     // the on-screen fiber (null on first mount)
  workInProgress: Fiber,     // the fiber being built
  renderLanes: Lanes,
): Fiber | null              // returns the next child to process, or null

beginWork has one job: reconcile this fiber's children and return the first child fiber, or return null if there are no children.

The first thing it does is check whether it can bail out — skip this fiber entirely:

if (current !== null) {
  const oldProps = current.memoizedProps;
  const newProps = workInProgress.pendingProps;

  if (oldProps === newProps && !hasContextChanged()) {
    // Props haven't changed — check if there's any pending work
    if (!includesSomeLane(renderLanes, updateLanes)) {
      // Nothing to do — bail out and clone children from current tree
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  }
}

If props haven’t changed and there are no pending updates in this fiber’s lanes, React clones the children from the existing current tree rather than re-running the component. This is the mechanism behind React.memo — if the props object reference hasn't changed, beginWork bails out before ever calling your component function.

If it can’t bail out, it dispatches on the fiber’s tag:

switch (workInProgress.tag) {
  case FunctionComponent:
    return updateFunctionComponent(current, workInProgress, renderLanes);
case ClassComponent:
    return updateClassComponent(current, workInProgress, renderLanes);
case HostRoot:
    return updateHostRoot(current, workInProgress, renderLanes);
case HostComponent:  // <div>, <span>, etc.
    return updateHostComponent(current, workInProgress, renderLanes);
case HostText:
    return updateHostText(current, workInProgress);

  // ... more cases
}

Let’s look at what each major path actually does.

Function Components — updateFunctionComponent

This is the path your everyday components take:

function updateFunctionComponent(current, workInProgress, renderLanes) {
  // Calls your actual component function
  const nextChildren = renderWithHooks(
    current,
    workInProgress,
    Component,
    newProps,
    renderLanes,
  );
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

renderWithHooks is where your component function is actually called. Before calling it, React sets up the internal hook dispatcher — this is what makes useState, useEffect, etc. available inside your component. After the call, it tears the dispatcher back down to prevent hooks from being called outside of rendering.

The JSX your component returns is passed into reconcileChildren.

reconcileChildren — The Heart of the Render Phase

This is where React figures out what changed. It compares the new React elements (what your component just returned) against the existing fiber children (what was rendered last time):

function reconcileChildren(current, workInProgress, nextChildren, renderLanes) {
  if (current === null) {
    // First mount — no existing children to compare against
    workInProgress.child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
  } else {
    // Update — diff new elements against existing fibers
    workInProgress.child = reconcileChildFibers(workInProgress, current.child, nextChildren, renderLanes);
  }
}

reconcileChildFibers is the diffing algorithm. It handles:

Single elements — match by type and key. If both match, reuse the existing fiber (update it). If either differs, mark the old one for deletion and create a new fiber.
Arrays / multiple children — first pass matches by index and key. Second pass handles remaining new children and cleans up unmatched old fibers.
Text nodes — compared by string value.
The key outcome of reconciliation is flags being set on fibers:

// New fiber that didn't exist before
newFiber.flags |= Placement;   // 2 — needs to be inserted
// Existing fiber with changed props
existingFiber.flags |= Update; // 4 — needs prop update
// Fiber that no longer exists in new output
oldFiber.flags |= Deletion;    // 8 — needs to be removed

These flags are not acted on yet — that’s the commit phase’s job. For now, beginWork is just marking what needs to happen.

Host Components — updateHostComponent

For native DOM elements like

or , beginWork doesn't call any user code. It just reconciles the children:
function updateHostComponent(current, workInProgress, renderLanes) {
  const nextProps = workInProgress.pendingProps;
  let nextChildren = nextProps.children;
 // If the only child is a plain string/number, treat it as a text node
  // and don't create a child fiber for it — handle it directly on the DOM node
  const isDirectTextChild = shouldSetTextContent(type, nextProps);
if (isDirectTextChild) {
    nextChildren = null; // no child fiber needed
  }
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

One subtle optimization here: if a host component’s only child is plain text (

Hello

), React doesn't create a HostText fiber for it. The text is set directly on the DOM node later in completeWork, saving a fiber allocation.

completeWork — Exiting a Fiber
Once beginWork returns null — meaning a fiber has no children left to process — React calls completeUnitOfWork, which walks back up the tree calling completeWork on each fiber it exits.

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null

completeWork is where the actual DOM nodes are created or updated. It's also where flags from the subtree bubble up to the parent.

Again, it dispatches on tag:

Host Components — Building the DOM Node

On first mount (no existing current fiber):

case HostComponent: {
  if (current === null) {
    // Create the actual DOM node
    const instance = createInstance(
      type,        // 'div', 'span', etc.
      newProps,
      rootContainerInstance,
      workInProgress,
    );
// Append already-completed children into this DOM node
    appendAllChildren(instance, workInProgress);
// Attach the DOM node to the fiber
    workInProgress.stateNode = instance;
// Set initial props (className, style, event listeners, etc.)
    finalizeInitialChildren(instance, type, newProps);
  }
}

This is a critical insight: child DOM nodes are appended to their parent during completeWork, bottom-up. By the time completeWork runs on a fiber, all its descendants have already completed — meaning their DOM nodes already exist and are already assembled into a subtree. appendAllChildren just attaches that subtree to the current node.

The result: by the time completeWork reaches the root, the entire DOM tree has been built in memory — fully assembled, just not yet inserted into the actual document.

On update (existing current fiber exists):

} else {
    // DOM node already exists — figure out what props changed
    const updatePayload = prepareUpdate(instance, type, oldProps, newProps);
// Store the diff on the fiber — applied during commit
    workInProgress.updateQueue = updatePayload;
if (updatePayload !== null) {
      workInProgress.flags |= Update;
    }
  }

prepareUpdate diffs the old and new props and returns an array of [key, value, key, value, ...] pairs representing only what changed. This payload sits on the fiber's updateQueue until the commit phase, where it's applied to the real DOM in one pass.

Bubbling Flags Up the Tree

After processing the fiber itself, completeWork does something subtle but very important — it bubbles flags up:

function bubbleProperties(completedWork: Fiber) {
  let subtreeFlags = NoFlags;
  let child = completedWork.child;
 while (child !== null) {
    subtreeFlags |= child.subtreeFlags;  // collect from grandchildren
    subtreeFlags |= child.flags;         // collect from direct children
    child = child.sibling;
  }
completedWork.subtreeFlags |= subtreeFlags;
}

Every fiber’s subtreeFlags is an OR of all flags values from every fiber in the subtree below it. This gives the commit phase the ability to do a fast check at any node — if subtreeFlags === 0, there is no work to do anywhere in this entire subtree, and React can skip it entirely. This is a major performance optimization during updates where most of the tree is unchanged.

What About completeUnitOfWork?

After completeWork finishes a fiber, React needs to decide where to go next. That logic lives in completeUnitOfWork:

function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork;
do {
    completeWork(current, completedWork, renderLanes);
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
      // There's a sibling — go process it (beginWork will run on it next)
      workInProgress = siblingFiber;
      return;
    }
// No sibling — move up to the parent
    completedWork = completedWork.return;
    workInProgress = completedWork;
} while (completedWork !== null);
}

The priority is: sibling before parent. After completing a fiber, if it has a sibling, React pivots to that sibling (which will go through beginWork again). Only when there are no more siblings does React move up to the parent. This ensures the entire subtree of each node is fully processed before moving on.

The Full Picture

Let’s trace through a small example end to end. Given:

function App() {
  return (
    <div className="app">
      <h1>Hello</h1>
      <p>World</p>
    </div>
  );
}

The render phase plays out like this:

beginWork(HostRoot)       → returns App fiber
  beginWork(App)          → calls App(), reconciles children, returns div fiber
    beginWork(div)        → reconciles children, returns h1 fiber
      beginWork(h1)       → reconciles children (text), returns null
      completeWork(h1)    → creates <h1> DOM node, appends text

      beginWork(p)        → reconciles children (text), returns null
      completeWork(p)     → creates <p> DOM node, appends text

    completeWork(div)     → creates <div>, appends <h1> and <p> into it
  completeWork(App)       → bubbles flags, no DOM node (function component)
completeWork(HostRoot)    → bubbles flags, subtree fully built

By the end of this traversal:

  • Every fiber has been visited exactly once via beginWork and once via completeWork
  • Every host fiber has a real DOM node attached to its stateNode
  • Every fiber that needs DOM work has the appropriate flags set
  • subtreeFlags on every fiber accurately reflects the total work needed in its subtree
  • The entire DOM subtree is assembled in memory, ready to be inserted

What the Render Phase Does NOT Do

It’s worth being explicit: the render phase never touches the live DOM. No insertions, no attribute updates, no deletions happen here. The render phase is designed to be:

Pure — no observable side effects on the real DOM
Interruptible — in concurrent mode, React can pause mid-traversal and discard the WIP tree entirely if higher-priority work comes in, without corrupting what’s on screen
All the actual DOM mutations happen in the commit phase, which picks up the finished WIP tree, walks the flags and subtreeFlags, and applies every queued change in a single synchronous pass that cannot be interrupted.

That’s exactly where we’re headed next.

The Commit Phase

The render phase was all about figuring out what changed. The commit phase is about making it happen. This is where React takes the finished work-in-progress tree with all its flags, update payloads, and assembled DOM nodes and actually applies it to the screen.

Unlike the render phase, the commit phase is always synchronous and cannot be interrupted. Once it starts, it runs to completion. The reason is simple: you can’t show the user a half-updated DOM. The commit phase is an all-or-nothing operation.

It’s driven by a single entry point:

function commitRoot(root: FiberRootNode) {
  const finishedWork = root.finishedWork; // the completed WIP tree

  commitRootImpl(root, finishedWork);
}

Internally, commitRootImpl runs the commit phase in three distinct sub-phases, each walking the fiber tree separately, each with a specific responsibility. Understanding why there are three passes — not one — is the key to understanding the commit phase.

Why Three Passes?

Intuitively you might think React could just walk the tree once and apply everything. The reason it can’t is ordering guarantees.

Effects need to fire in a specific sequence relative to the DOM state. For example:

A parent component’s useLayoutEffect cleanup must run before a child's useLayoutEffect setup
The DOM must be fully mutated before any useLayoutEffect fires
useEffect must fire after the browser has had a chance to paint
If React did everything in one pass, it couldn’t guarantee these orderings. Three separate passes solve this cleanly.

Sub-phase 1: commitBeforeMutationEffects

Before anything touches the DOM, React runs this pass. At this point, the current tree is still on screen — the DOM is unchanged.

function commitBeforeMutationEffects(root, finishedWork) {
  while (nextEffect !== null) {
    const fiber = nextEffect;

    // Fire getSnapshotBeforeUpdate on class components
    if ((fiber.flags & Snapshot) !== NoFlags) {
      commitBeforeMutationEffectOnFiber(fiber);
    }nextEffect = nextEffect.nextEffect;
  }
}

The main job of this pass is firing getSnapshotBeforeUpdate on class components. This lifecycle method is specifically designed to capture information from the DOM before it changes — for example, capturing a scroll position before a list update so you can restore it after.

function commitBeforeMutationEffectOnFiber(finishedWork) {
  switch (finishedWork.tag) {
    case ClassComponent: {
      const instance = finishedWork.stateNode;
      const snapshot = instance.getSnapshotBeforeUpdate(
        finishedWork.elementType === finishedWork.type
          ? prevProps
          : resolveDefaultProps(finishedWork.type, prevProps),
        prevState,
      );
      // Stored on the instance, available in componentDidUpdate
      instance.__reactInternalSnapshotBeforeUpdate = snapshot;
    }
  }
}

This pass also schedules useEffect cleanups and setups via the Scheduler but importantly, it only schedules them here. They don't run until after the browser paints. More on this later.

Sub-phase 2: commitMutationEffects

This is the pass where the DOM is actually mutated. React walks the tree and applies every insertion, update, and deletion that was flagged during the render phase.

function commitMutationEffects(root, finishedWork, committedLanes) {
  while (nextEffect !== null) {
    const fiber = nextEffect;
    const flags = fiber.flags;
// Handle deletions first
    if (flags & ChildDeletion) {
      commitDeletionEffects(root, fiber);
    }
// Then insertions and updates
    const primaryFlags = flags & (Placement | Update | Hydrating);
    switch (primaryFlags) {
      case Placement:
        commitPlacementEffects(fiber);
        fiber.flags &= ~Placement;
        break;
      case Update:
        commitUpdateEffects(fiber);
        break;
      case PlacementAndUpdate:
        commitPlacementEffects(fiber);
        fiber.flags &= ~Placement;
        commitUpdateEffects(fiber);
        break;
    }
nextEffect = nextEffect.nextEffect;
  }
}

Let’s look at each mutation type in detail.

Deletions — commitDeletionEffects

Deletions are handled first and are the most involved. When a fiber is deleted, React can’t just remove the DOM node — it also needs to clean up everything inside that fiber’s subtree.

function commitDeletionEffects(root, returnFiber, deletedFiber) {
  // Walk the subtree being deleted
  // For every fiber found, in child-first order:
  // 1. Call useEffect and useLayoutEffect cleanups
  // 2. Call componentWillUnmount on class components
  // 3. Detach refs (set ref.current = null)
  // 4. Remove the actual DOM node

  commitDeletionEffectsOnFiber(root, returnFiber, deletedFiber);
}

The cleanup order inside a deleted subtree is child-before-parent — a child’s componentWillUnmount fires before its parent's. This mirrors the mount order in reverse, which is what React's docs guarantee.

Refs are explicitly nulled out before cleanups fire:

// Refs are detached before effects clean up
safelyDetachRef(deletedFiber);
// Then cleanup effects run
safelyCallDestroy(deletedFiber, nearestMountedAncestor, destroy);
Insertions  commitPlacementEffects

A fiber with the Placement flag needs its DOM node inserted into the document. React finds the correct parent DOM node and the correct sibling to insert before:

function commitPlacementEffects(finishedWork) {
  // Walk up the fiber tree to find the nearest host ancestor
  // (skipping function/class component fibers, which have no DOM node)
  const parentFiber = getHostParentFiber(finishedWork);

  switch (parentFiber.tag) {
    case HostComponent: {
      const parent = parentFiber.stateNode; // the real DOM node
      const before = getHostSibling(finishedWork); // DOM node to insert before

      insertOrAppendPlacementNode(finishedWork, before, parent);
      break;
    }
    case HostRoot: {
      const parent = parentFiber.stateNode.containerInfo;
      const before = getHostSibling(finishedWork);
      insertOrAppendPlacementNode(finishedWork, before, parent);
      break;
    }
  }
}

getHostSibling is surprisingly tricky — it has to walk siblings and potentially descend into their subtrees to find the correct DOM anchor. This is because fiber siblings don't always map 1:1 to DOM siblings (a fiber sibling might be a component that renders multiple DOM nodes).

function insertOrAppendPlacementNode(node, before, parent) {
  if (before) {
    parent.insertBefore(getStateNode(node), before);
  } else {
    parent.appendChild(getStateNode(node));
  }
}

Updates — commitUpdateEffects

A fiber with the Update flag has an existing DOM node whose props changed. Remember from completeWork: the diff was already computed and stored as updateQueue on the fiber. Now React just applies it:

function commitUpdateEffects(finishedWork) {
  switch (finishedWork.tag) {
    case HostComponent: {
      const instance = finishedWork.stateNode; // the real DOM node
      const updatePayload = finishedWork.updateQueue; // [key, val, key, val, ...]

      if (updatePayload !== null) {
        commitUpdate(instance, updatePayload, finishedWork);
      }
      break;
    }
    case HostText: {
      const textInstance = finishedWork.stateNode;
      const newText = finishedWork.memoizedProps;
      commitTextUpdate(textInstance, oldText, newText);
      break;
    }
  }
}

function commitUpdate(domElement, updatePayload) {
  // updatePayload is a flat [key, value, key, value] array
  for (let i = 0; i < updatePayload.length; i += 2) {
    const propKey = updatePayload[i];
    const propValue = updatePayload[i + 1];

    if (propKey === 'style') {
      setValueForStyles(domElement, propValue);
    } else if (propKey === 'children') {
      setTextContent(domElement, propValue);
    } else {
      setValueForProperty(domElement, propKey, propValue);
    }
  }
}

The flat [key, value] array format means this loop is extremely cache-friendly. No object creation, no key iteration — just a tight loop applying pre-computed diffs.

The Tree Swap — After Mutation

After commitMutationEffects completes, React does something fundamental:

root.current = finishedWork;
This single line is the tree swap. The work-in-progress tree becomes the current tree. From this point forward, the current tree reflects what’s on screen.

This is carefully timed. It happens after mutations (socomponentWillUnmount and useLayoutEffect cleanups can read the old tree) but before the next pass (so componentDidMount and useLayoutEffect setups see the new tree as current).

** Sub-phase 3: commitLayoutEffects**

At this point, the DOM is fully updated and the tree swap has happened. Now React fires effects that need to run synchronously after the DOM is mutated but before the browser paints.

function commitLayoutEffects(finishedWork, root, committedLanes) {
  while (nextEffect !== null) {
    const fiber = nextEffect;
    const flags = fiber.flags;
if (flags & (Update | Callback)) {
      commitLayoutEffectOnFiber(root, current, fiber, committedLanes);
    }
// Attach refs now that DOM nodes are in place
    if (flags & Ref) {
      commitAttachRef(fiber);
    }
nextEffect = nextEffect.nextEffect;
  }
}

useLayoutEffect

useLayoutEffect fires synchronously in this pass. The order is child-before-parent — children's layout effects run before their parent's:

function commitLayoutEffectOnFiber(root, current, finishedWork) {
  switch (finishedWork.tag) {
    case FunctionComponent: {
      // First run cleanups of previous layout effects
      commitHookEffectListUnmount(HookLayout, finishedWork);
      // Then run the new layout effects
      commitHookEffectListMount(HookLayout, finishedWork);
      break;
    }
    case ClassComponent: {
      const instance = finishedWork.stateNode;
      if (current === null) {
        // First mount
        instance.componentDidMount();
      } else {
        // Update
        instance.componentDidUpdate(
          prevProps,
          prevState,
          instance.__reactInternalSnapshotBeforeUpdate, // from pass 1
        );
      }
      break;
    }
  }
}

This is exactly why useLayoutEffect is appropriate for measuring DOM layout when it fires, the DOM has been mutated but the browser hasn't painted yet. You can read getBoundingClientRect() here and the values reflect the new DOM state, without any visual flicker.

Ref Attachment
Also in this pass, refs are attached to their new values:

function commitAttachRef(finishedWork) {
  const ref = finishedWork.ref;
  if (ref !== null) {
    const instance = finishedWork.stateNode;
   if (typeof ref === 'function') {
      ref(instance); // callback ref
    } else {
      ref.current = instance; // ref object
    }
  }
}

The timing matters here: refs are nulled out during deletions in pass 2, and attached at the end of pass 3. So within a single commit, if a component is replaced (old deleted, new inserted), the ref correctly transitions from the old instance to the new one.

useEffect — After the Paint

useEffect is deliberately not part of the three synchronous passes above. It is scheduled to run after the browser has had a chance to paint.

// Scheduled at the end of commitRootImpl
scheduleCallback(NormalSchedulerPriority, () => {
  flushPassiveEffects();
});
flushPassiveEffects runs in two steps, and the order is strict:

Step 1  All cleanups first, across the entire tree:

function commitPassiveUnmountEffects(finishedWork) {
  // Walk the tree running all useEffect cleanup functions
  // This runs for ALL fibers before any new effects are set up
  commitHookEffectListUnmount(HookPassive, finishedWork);
}
Step 2  All setups, across the entire tree:

function commitPassiveMountEffects(root, finishedWork) {
  // Walk the tree running all useEffect setup functions
  commitHookEffectListMount(HookPassive, finishedWork);
}

The separation is critical. Consider a parent and child both having useEffect. React guarantees:

// Wrong mental model (NOT how it works):
parent cleanup → parent setup → child cleanup → child setup
// Correct (how it actually works):
child cleanup → parent cleanup → child setup → parent setup
All cleanups across the entire tree run before any new setups. This prevents a situation where a parent’s new effect fires before a child’s cleanup from the previous render has finished which could cause subtle bugs if they share state or refs.

Putting the Whole Commit Phase Together

commitRoot(root)
│
├── Sub-phase 1: commitBeforeMutationEffects
│   ├── getSnapshotBeforeUpdate (class components)
│   └── Schedule useEffect cleanup/setup (not run yet)
│
├── Sub-phase 2: commitMutationEffects        ← DOM is mutated here
│   ├── commitDeletionEffects
│   │   ├── useLayoutEffect cleanups (deleted fibers)
│   │   ├── componentWillUnmount
│   │   ├── ref detachment
│   │   └── DOM node removal
│   ├── commitPlacementEffects  (insertBefore / appendChild)
│   └── commitUpdateEffects     (apply updateQueue to DOM)
│
├── root.current = finishedWork               ← TREE SWAP
│
├── Sub-phase 3: commitLayoutEffects          ← DOM mutated, not yet painted
│   ├── useLayoutEffect cleanups (updated fibers)
│   ├── useLayoutEffect setups
│   ├── componentDidMount / componentDidUpdate
│   └── ref attachment
│
└── scheduleCallback(flushPassiveEffects)     ← Scheduled for after paint
    ├── All useEffect cleanups (entire tree)
    └── All useEffect setups (entire tree)

The Subtree Optimization in the Commit Phase

Remember subtreeFlags from the render phase? This is where it pays off.

Before descending into any subtree during the commit passes, React checks:

if ((finishedWork.subtreeFlags & targetFlags) === NoFlags) {
  // No relevant work anywhere in this subtree — skip it entirely
  return;
}

If a subtree had no changes no insertions, no updates, no effects — React skips the entire branch in every one of the three passes. On a large tree where only a small part changed, this is an enormous saving.

What Happens After Commit

Once all three passes complete and useEffects are scheduled:

React checks if any synchronous updates were triggered during the commit (e.g., a setState inside componentDidMount). If so, it immediately starts a new render.
If useEffect fires and triggers state updates, those are batched and scheduled as a new render at normal priority.
The WIP tree from this commit becomes the current tree, and the old current tree becomes the pool for the next WIP tree via alternate.
The cycle then begins again scheduler picks up the next unit of work, the render phase builds a new WIP tree, and the commit phase applies it. This loop is the heartbeat of React’s runtime.

Why This Design?

The three-pass commit design, the tree swap timing, the separation of useLayoutEffect and useEffect — none of this is arbitrary. It's built around a set of guarantees React makes to you:

getSnapshotBeforeUpdate always sees the pre-mutation DOM
componentWillUnmount / useLayoutEffect cleanup always sees the DOM as it existed before this render's mutations
componentDidMount / useLayoutEffect setup always fires synchronously before the browser paints, with the new DOM in place
useEffect always runs after the browser has painted, never blocking the frame
Refs always point to the current, mounted instance — never to a stale or unmounted one
Every structural decision in the commit phase exists to enforce one of these guarantees. The complexity isn’t incidental it’s the cost of making React’s effect model predictable and correct.

Here is React DOM package looks like:

react-dom-package-img

Final Thoughts

We started with a single empty

and ended with pixels on a screen.
JSX you write
      ↓
React Elements — plain JS objects describing what you want
      ↓
Fiber Tree — React's internal shadow world, built during render phase
      ↓
beginWork — walks down, reconciles children, marks what changed
      ↓
completeWork — walks up, creates DOM nodes in memory, bubbles flags
      ↓
Commit Phase — three passes that write to the real DOM
      ↓
Browser Critical Rendering Path — style, layout, paint, composite
      ↓
Pixels on screen

What happens in between is one of the most carefully engineered pipelines in frontend development and now you understand all of it.

— — — — — — — — — — — — The End — — — — — — — — — — — —

thankyou-image

“Build things that you want to see” — Vishal Desai

Top comments (0)