DEV Community

Phạm Hồng Phúc
Phạm Hồng Phúc

Posted on

React Rendering Pipeline

Overview

This article will analyze the rendering pipeline of React from version 16 onward, when React Fiber was introduced, focused on React 18 Concurrent Mode. Instead of describing the old lifecycle model (mounting → updating → unmounting) tied to Class components, this article will follow the new model: Render phase, Commit Phase, and Effect Phase (illustrated in below image).

Common architecture of React Rendering Pipeline

When React 16 introduced Fiber in 2017, the library was fully rewritten to support an interruptible rendering model, which was impossible in previous stack-based architecture. Before React Fiber, reconciliation was synchronous and could not be interrupted midway. This causes “jank”, missing frames when the component's tree grows too large. React Fiber solved the problem by breaking down the work into small units (fiber nodes), and allowing React to pause, resume or cancel the work in progress.

React 18 continually expanded the capacity with Concurrent Mode, a rendering mode that allows multiple UI versions to exist at the same time, and React actively prioritizes tasks based on their importance.

Background

Virtual DOM and Fiber tree

Virtual DOM refers to the React Element tree (component tree), plain JavaScript objects produced by JSX. When you write , React creates {type: Button, props: {color: "blue"},...}. These objects are cheap to create compared to interacting with the real DOM.

The Fiber tree is React’s internal representation, a separate, richer data structure that wraps the Virtual DOM and adds everything React needs to manage work over time. Each component in the tree maps to a fiber node, a JavaScript object that stores:

  • The component type and current props/state
  • A linked list of hooks attached to this component
  • A list effects that need to run (DOM mutations, layout effects. Passive effects)
  • Pointers to parent, first child, and next sibling fibers
  • Work-in-progress flags and priority metadata

The relationship between virtual DOM and fiber node is illustrated in below diagram

JSX

React.createElement()

React Element (Virtual DOM)


React Fiber (internal work unit) wraps the element, adds metadata

Double buffering: always two trees

React maintains two fiber trees simultaneously

  • Current: the fiber tree currently rendered on screen
  • Work-in-progress: the fiber tree being built during the current render

This pattern is called double buffering, borrowed from graphics rendering. During the Render Phase, React builds the work-in-progress tree by diffing it against current. After the Commit Phase completes, the trees swap, work-in-progress becomes the new current, and the old current is recycled for the next render.

BEFORE COMMIT:
current → [what's on screen]
work-in-progress → [what React computed]

AFTER COMMIT:
current → [formerly work-in-progress, now on screen]
work-in-progress → [recycled, ready for next render]

This swap is what makes the Commit Phase atomic, the user always sees either the old tree or the new tree, never a mix.

Work loop and time slicing

React Fiber uses two separate loops:

  • Work loop (Render Phase), interruptible. React processes one fiber node at a time and checks frequently whether the current deadline has passed. If it has, React yields control back to the browser via the MessageChannel API and schedules resumption as a macrotask.
  • Commit loop (Commit Phase), non-interruptible. Runs synchronously to completion.

Scheduler and Lane Model

React 18 uses a lane modal internally, a system where each update is assigned to one or more lanes. This allows React to batch updates with the same lane and process them together, while separating updates with different lanes to handle them independently.

Lane/Priority Timeout Typical trigger Note
Immediate/Sync Synchronous Error, emergency Block all other work
UserBlocking 250ms Click, keyboard input Highest interactive priority
Normal 500ms Data fetch, state update Default for most updates
Transition/Low 10000ms useTransition, useDeferredValue Can render off-screen in parallel with higher-priority work
Idle unlimited Prefetch, background prepare data Only runs when nothing else is queued

Key point: startTranstion does not just lower priority, it also signals that the update is safe to discard and restart if a higher-priority update arrives. This has direct implications idempotency.

Render Phase

The Render Phase is where React calls component functions, builds the work-in-progress fiber tree, and runs the Reconciliation algorithm to compare it against the current tree. The output is an effect list, a linked list of fiber nodes that require action in the Commit Phase. No DOM mutation occurs here.

Purity and Idempotency

Render Phase must be pure: given the same props and state, a component function must return the same React Element tree. The rule exists because the Render Phase can execute multiple times before the final result is applied (React may discard a work-in-progress tree and restart from scratch). In Concurrent Mode, when React discards and restarts a render:

  • Update queues on fiber nodes are preserved: pending state updates are not lost
  • Local variables inside the component function are lost: they belong to the discarded execution context
  • Side effects triggered during render cannot be cancelled: network requests continue running in the background and may return stale or unrelated data.

Note: In the development, React StrictMode intentionally calls component functions twice to detect purity violations.

Reconciliation and Diffing algorithm

Reconciliation is the process by which React compares the old (current) Fiber tree with the new Fiber tree being built (work-in-progress). React uses two main heuristics to reduce complexity from O(n³) to O(n):

  • Element type assumption: If the type of a node changes (e.g., from to ), React destroys the entire old subtree and rebuilds from scratch.
  • The role of keys: In a list, the key allows React to exactly identify which element has been moved, added, or removed without comparing the entire list.

Note: Common key error:

  • Don’t use index as key in a list can be re-order (items.map((item, i) => ))
  • Instead, use stable identifier (items.map(item => ))

After diffing, React annotates each fiber node that requires action with a tag

  • Placement: this node needs to be inserted into the DOM
  • Update: this node’s attributes or text need to change
  • Deletion: this node needs to be removed

These annotated nodes are linked together into the effect list, which is the direct input to the Commit Phase. The Render Phase produces this list; the Commit Phase consumes it.

How do hooks work inside the Render Phase?

hook flow in render phase

Hooks are stored as a linked list on the fiber's memoizedState field. This is documented directly in ReactFiberHooks.js. Every time a hook is called during the first render (mount), React runs mountWorkInProgressHook(), which creates a new node and appends it to the list:

function mountWorkInProgressHook(): Hook {
  const hook = {
    memoizedState: null,  // stores the hook's value
    baseState: null,
    queue: null,          // update queue (useState/useReducer)
    next: null,           // pointer to the next hook node
  };

  if (workInProgressHook === null) {
    // first hook — becomes the head of the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // subsequent hooks — appended to the tail
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

On every subsequent render (update), React switches to updateWorkInProgressHook(), which walks this list from the head — advancing one node per hook call, in strict call order. There is no name lookup, no key, no identifier — just sequential pointer traversal. This is the mechanical reason hooks cannot be called inside conditionals or loops: if a hook call is skipped, every node from that point onward is read by the wrong hook. Node 3's memoizedState gets read as if it belongs to Node 2's useEffect, and so on — silently producing wrong values with no error until the node count itself mismatches.

useEffect works differently from other hooks in one important way, it participates in two separate phases at two different points in time:

  • During the Render Phase, mountWorkInProgressHook() creates Node 2 and stores the dependency array in memoizedState. On re-renders, React reads Node 2, compares the new deps against the stored ones using Object.is, and, if anything changed, marks the fiber with a PassiveEffect flag. The effect function is not touched here. React is only deciding whether it needs to run later.
  • During the Effect Phase, after the browser has painted, React finds every fiber carrying the PassiveEffect flag, runs the cleanup function stored from the previous render, then runs the new effect function. This is the only point where () => { fetch(...) } actually executes.

The split exists because the Render Phase must remain pure and interruptible, calling an effect function there would violate both properties.

More detail, behavior of each hook during the Render Phase is illustrated in below table

Hook What happens during Render Phase
useState Returns the current value from the linked list node. The setter does not change state immediately; it enqueues an update into the fiber's update queue. The Scheduler decides when to re-render.
useReducer Similar to useState but with a reducer function.
useEffect Compares the dependency array via Object.is. If changed, marks the fiber with PassiveEffect. The effect function is not called here—it is scheduled to run in the Effect Phase after paint.
useLayoutEffect Same as useEffect. If changed, marks the fiber with HookLayout. The effect function is not called here; it runs in the Commit Phase Layout sub-phase, before paint.
useMemo Compares the dependency array using Object.is shallow equality. If any dependency has changed, recomputes the value and stores it in the node. Otherwise, returns the cached value. If dependencies are objects or arrays recreated every render, the memo is always invalidated.
useCallback Similar to useMemo; memoizes the function reference.
useRef Returns the same object { current: ... } on every render. Mutating .current does not enqueue a re-render and is invisible to React's reconciliation.
useContext Subscribes the component to a context. When the context value changes, React schedules a re-render of this component regardless of React.memo.

Batching and Concurrent Mode

In React 18, calling multiple setters within the same synchronous event handler, setTimeout, Promise.then, or native event listener results in a single re-render, all updates are batched. React collects them all into the fiber's update queue, then processes them in one pass during the next Render Phase. This is automatic batching, expanded from React 17 which only batched inside React event handlers.

With Concurrent Mode, the Render Phase is interruptible. React processes fibers one at a time in the work loop and checks after each unit of work whether a higher-priority update has arrived. If it has, React:

  • (1) discards the current work-in-progress tree
  • (2) processes the higher-priority update
  • (3) restarts the lower-priority render from scratch

Commit Phase

After the Render Phase completes and the effect list is finalized, React enters the Commit Phase, the phase where it actually interacts with the real DOM. Unlike the Render Phase, the Commit Phase cannot be interrupted and runs completely synchronously. The reason is that if interrupted midway, the user will see an inconsistent interface, part of it updated, part still old.

Three stages of Commit Phase

The Commit Phase is divided into three sequential steps, each step traversing the entire Fiber tree in order bottom-up (children first, parents second)

  • (1) Before Mutation: react reads DOM state before any changes are made. Why is this necessary? Some DOM properties, scroll position, text selection state, change unpredictably once the DOM is mutated. Reading them here, before Mutation, gives components a reliable snapshot. The snapshot is then passed to componentDidUpdate or stored in a ref for use in useLayoutEffect.
  • (2) Mutation: react applies the effect list to the real DOM. This is the only step where react actually interacts with the real DOM. It does not re-render the entire DOM tree, it applies the minimum set of changes computed by reconciliation. After the Mutation sub-phase completes, React swaps the two fiber trees: work-in-progress becomes the new current. For each tagged fiber node:
    • Placement → parentNode.appendChild(node) or parentNode.insertBefore(node, anchor)
    • Update → updates specific attributes, className, style properties, or text content
    • Deletion → parentNode.removeChild(node), runs componentWillUnmount / cleanup for useLayoutEffect
  • (3) Layout: React runs useLayoutEffect (and componentDidMount / componentDidUpdate for Class Components) in this sub-phase. The DOM reflects the new state, but the browser has not yet painted.

After step 3, React relinquishes control of the main flow to the browser. The browser then actually paints, redraws the pixels onto the screen. This is the boundary between the Commit Phase and the Effect Phase.

The architecture helps React prevent Layout Thrashing, which occurs when code alternates between reading and writing to the DOM within the same frame. Based on the above architecture, all writing tasks are done in Mutation step; reading tasks are executed via useLayoutEffect, after writing.

useLayoutEffect

useLayoutEffect runs at the step 3, after React has updated the DOM but before the browser paints. This is the only time the DOM can be read and written synchronously without flickering, as the user hasn't seen any changes yet.

useLayoutEffect common use cases

  • Measuring element size/position: getBoundingClientRect(), offsetHeight, scrollWidth…
  • Setting focus: ref.current.focus() immediately after an element appears in the DOM
  • Synchronizing external animation libraries
  • Calculating tooltip/popover position based on the actual DOM size

When useLayoutEffect calls setState, React flushes synchronously, implementing the new Render Phase and the new Commit Phase immediately, before handing control to the browser. Users only see the final result, not the intermediate state. This is the core difference compared to useEffect.

Warning about useLayoutEffect

  • Because useLayoutEffect blocks browser paint, it can cause stuttering if heavy calculations are performed.
  • useLayoutEffect should not be used for operations that do not need to synchronize with DOM paint.
  • Server-Side Rendering (SSR): useLayoutEffect does not run on the server, use useEffect or check the typeof window !== "undefined".

Effect Phase

The Effect Phase is the final stage, occurring after the browser has finished painting. Effects are executed asynchronously and do not block the main thread, ensuring the UI remains responsive to the user.

useEffect

React schedules useEffect via the MessageChannel API (not a microtask like Promise, and doesn’t like setTimeout). This ensures the effect runs after the browser paints, but still much earlier than setTimeout.

  • Microtask (Promise.then): runs before the browser has a chance to paint. If effects ran as microtasks, they would execute before the user sees the new UI — blocking paint.
  • setTimeout(fn, 0): runs after paint, but browsers throttle it (minimum ~4ms; more when the tab is in the background). Chained setTimeout calls accumulate significant delay.
  • MessageChannel: runs after paint, not throttled, classified as a macrotask. React uses it to run effects as early as possible after paint without blocking it.

This is why useEffect "feels fast" despite being asynchronous, it runs in the first available macrotask after the browser paints, typically within a single frame. The useEffect render lifecycle is:

  • React runs cleanup function (return callback) of effect from previous render
  • React runs the new effect function

Both cleanup function and effect function run bottom-up (Children → Parent → App). React ensures that the child component's cleanup runs before the parent component's cleanup. This helps the parent component remain "alive" while the child component is cleaning up, preventing the child from needing to access the parent's resources but the parent has already cleaned up.

Question: Why doesn’t useEffect run in Render Phase?

Effects usually interact with external services (API, WebSocket, browser APIs), these are not idempotent. If useEffect ran during render and React cancelled that render midway, the network request would still be in-flight. React cannot cancel it. The app might receive a response from a zombie request and update state with stale or unrelated data.

Suspense and the Pipeline

Suspense is worth understanding as a concrete example of "multiple UI versions in memory at the same time" — the core promise of Concurrent Mode.
When a component suspends (throws a Promise during render), React does not commit that subtree. Instead:

  • React renders the nearest Suspense boundary's fallback in place of the suspended subtree
  • The suspended work-in-progress tree is kept in memory — not discarded
  • When the Promise resolves, React retries rendering the suspended subtree from the beginning
  • If the retry succeeds, React replaces the fallback with the real content in a single atomic commit

Component throws Promise during Render Phase
|
React catches it at the nearest boundary
|
Renders fallback (e.g., ) to screen
|
Keeps suspended subtree in memory (off-screen)
|
Promise resolves
|
React retries Render Phase for suspended subtree
|
If successful: Commit Phase swaps fallback → real content

This is why component functions used inside Suspense must be pure and idempotent — React will call them more than once before committing, and the results must be consistent.

Conclusion

The three-phase model, Render → Commit → Effect, is not just a description of what the Class Component lifecycle does. It's a new way of thinking that accurately reflects the internal architecture of React Fiber and forms the foundation for understanding advanced features like Concurrent Mode, Suspense, and Server Components. Three core principles to remember:

  • The Render Phase must be pure: No side effects, no interaction with the DOM or external services.
  • The Commit Phase is the boundary of the DOM: All operations with the actual DOM occur here, synchronously and uninterruptible.
  • The Effect Phase is where side effects occur: Asynchronous, after the UI has rendered, with a clear cleanup.

Understanding these three phases allows developers to make the right decisions about where to place logic, choose the right hooks, and design components compatible with Concurrent Mode — an increasingly important requirement as React continues to evolve towards interruptible, prioritized rendering.

Reference

Top comments (0)