Read OSS

The Work Loop — How React Renders

Advanced

Prerequisites

  • Article 1: Architecture Overview (monorepo structure and fork system)
  • Article 2: Fiber Data Structure (Fiber fields, WorkTags, flags, lanes concept)
  • Comfort with bitwise operations for lane manipulation

The Work Loop — How React Renders

The work loop is the beating heart of React. It's the code that turns a setState call into pixels on screen. At ~5,600 lines, ReactFiberWorkLoop.js is the single most important file in the codebase, orchestrating the render phase (building the workInProgress tree), the lane model (deciding what priority work to do), and the commit phase (applying effects to the host).

This article traces the complete lifecycle of a state update: from the moment you call setState, through lane assignment and scheduling, into the work loops that process fibers, and finally through the three commit sub-phases that mutate the DOM.

The Lane Model — Priority as Bitmasks

Before we can understand scheduling, we need to understand lanes. Defined in ReactFiberLane.js, lanes are a 31-bit bitmask system that encodes work priority.

Each bit position represents a different priority level:

Lane Bit Position Purpose
SyncHydrationLane 0 Highest priority hydration
SyncLane 1 Synchronous updates (e.g., controlled inputs)
InputContinuousLane 3 Continuous events (e.g., drag, scroll)
DefaultLane 5 Normal updates (e.g., setState from useEffect)
GestureLane 6 View transition gestures
TransitionLane1-14 8-21 startTransition updates (14 lanes for batching)
RetryLane1-4 22-25 Suspense retry attempts
IdleLane 28 Lowest priority work
OffscreenLane 29 Prerendering hidden content

The power of bitmasks is batching: multiple updates at the same priority level share the same lane bit, and React processes all of them in a single render pass. The TransitionLanes range has 14 lanes, which allows React to assign separate lanes to separate startTransition calls, enabling independent transition progress tracking.

graph LR
    subgraph "31-bit Lane Bitmask"
        SH["0: SyncHydration"]
        SY["1: Sync"]
        ICH["2: InputContinuousHydration"]
        IC["3: InputContinuous"]
        DH["4: DefaultHydration"]
        D["5: Default"]
        G["6: Gesture"]
        TH["7: TransitionHydration"]
        T["8-21: Transition × 14"]
        R["22-25: Retry × 4"]
        SEL["26: SelectiveHydration"]
        IH["27: IdleHydration"]
        ID["28: Idle"]
        OFF["29: Offscreen"]
        DEF["30: Deferred"]
    end

The function getNextLanes determines which lanes to work on next. It selects the highest-priority non-suspended lanes, considering entanglements (lanes that must be processed together) and expiration (starved lower-priority work gets promoted).

Scheduling an Update — setState to scheduleUpdateOnFiber

When you call setState, the journey to the screen begins with scheduleUpdateOnFiber:

sequenceDiagram
    participant User as User Code
    participant Dispatch as dispatchSetState
    participant Schedule as scheduleUpdateOnFiber
    participant Root as ensureRootIsScheduled
    participant Sched as Scheduler

    User->>Dispatch: setState(newValue)
    Dispatch->>Dispatch: Enqueue update on fiber
    Dispatch->>Dispatch: Request lane (requestUpdateLane)
    Dispatch->>Schedule: scheduleUpdateOnFiber(root, fiber, lane)
    Schedule->>Schedule: Mark root with pending lanes
    Schedule->>Root: ensureRootIsScheduled(root)
    Root->>Sched: scheduleCallback(priority, performWorkOnRoot)

The update is first enqueued on the fiber's update queue as a circular linked list node. A lane is requested based on the current context — if you're inside a startTransition callback, you get a TransitionLane; if it's a click handler, you get SyncLane; if it's from useEffect, you get DefaultLane.

scheduleUpdateOnFiber then marks the root's pendingLanes with the update's lane and calls ensureRootIsScheduled.

The Root Scheduler and the Scheduler Package

The ReactFiberRootScheduler manages a linked list of roots with pending work. It maps React's lane-based priorities to the Scheduler package's five priority levels, then schedules a callback via Scheduler_scheduleCallback.

The Scheduler package is React's cooperative scheduling layer. It maintains two priority queues:

  • taskQueue: Tasks ready to execute, sorted by expiration time
  • timerQueue: Delayed tasks not yet ready, sorted by start time

Both are implemented as min-heaps using the compact SchedulerMinHeap.js — push, pop, siftUp, siftDown operations in ~80 lines. The comparison function sorts by sortIndex first (expiration time), then by id (insertion order) as a tiebreaker.

The Scheduler uses MessageChannel to yield to the browser's event loop between task chunks. This is what enables concurrent rendering — React can pause work and let the browser handle user input, paint frames, or run other tasks.

flowchart TD
    RC["Root Scheduler"] -->|"maps lanes to priority"| SC["Scheduler"]
    SC --> TQ["taskQueue (min-heap)<br/>Ready tasks"]
    SC --> TMQ["timerQueue (min-heap)<br/>Delayed tasks"]
    SC -->|"MessageChannel"| EL["Browser Event Loop"]
    EL -->|"next message"| SC
    SC -->|"calls"| PWR["performWorkOnRoot"]

performWorkOnRoot — The Render Entry Point

performWorkOnRoot is the main function that orchestrates rendering. Its most critical decision: sync or concurrent?

const shouldTimeSlice =
  (!forceSync &&
    !includesBlockingLane(lanes) &&
    !includesExpiredLane(root, lanes)) ||
  checkIfRootIsPrerendering(root, lanes);

let exitStatus = shouldTimeSlice
  ? renderRootConcurrent(root, lanes)
  : renderRootSync(root, lanes, true);

Sync rendering is used for SyncLane (user interactions that need immediate response), expired work (to prevent starvation), and forced sync renders. Concurrent rendering is used for transitions and other non-blocking updates, enabling time-slicing.

After rendering completes, performWorkOnRoot checks the exit status. If the render was concurrent and external stores were mutated during the render (detected by isRenderConsistentWithExternalStores), it falls back to a synchronous re-render to ensure consistency.

The Inner Loops — workLoopSync and workLoopConcurrent

The actual rendering happens in tight while loops. The sync version at line 2750 is remarkably simple:

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

The concurrent version at line 3034 adds yielding:

function workLoopConcurrent(nonIdle: boolean) {
  if (workInProgress !== null) {
    const yieldAfter = now() + (nonIdle ? 25 : 5);
    do {
      performUnitOfWork(workInProgress);
    } while (workInProgress !== null && now() < yieldAfter);
  }
}

Note the difference in yield intervals: transitions get 25ms slices (intentionally throttling animations to ~30fps to prevent starvation), while idle work yields every 5ms.

performUnitOfWork calls beginWork to process the current fiber and get its child. If there's a child, it becomes the new workInProgress. If there's no child, completeUnitOfWork walks up through siblings and parents.

flowchart TD
    PUW["performUnitOfWork(fiber)"]
    BW["beginWork(current, wip, lanes)<br/>Returns child or null"]
    CUW["completeUnitOfWork(fiber)"]
    CW["completeWork(current, wip, lanes)"]

    PUW -->|"call"| BW
    BW -->|"child exists"| PUW2["wip = child<br/>Loop continues"]
    BW -->|"null (leaf node)"| CUW
    CUW --> CW
    CW -->|"has sibling"| PUW3["wip = sibling<br/>Loop continues"]
    CW -->|"no sibling"| CUW2["wip = return<br/>Walk up"]

Tip: The tree traversal performed by the work loop is essentially a non-recursive depth-first traversal using the fiber's linked-list pointers. beginWork descends into children; completeUnitOfWork handles the backtracking.

beginWork — Processing Each Fiber

beginWork is a massive function that dispatches on fiber.tag (the WorkTags we covered in Article 2) to specialized update functions:

  • FunctionComponentupdateFunctionComponent → calls renderWithHooks (invokes your component function)
  • HostComponentupdateHostComponent → reconciles children
  • SuspenseComponentupdateSuspenseComponent → manages fallback/content switching
  • MemoComponentupdateMemoComponent → checks if props changed

A critical optimization is the bailout path. If a fiber has no pending work (lanes don't include renderLanes) and its props haven't changed, beginWork can skip the entire subtree. This is why React.memo and useMemo matter — they enable bailouts that skip entire branches of the tree.

When beginWork processes a fiber, it triggers child reconciliation (the "diffing" algorithm) via ReactChildFiber.js. This is where key matching happens — React compares the old children with the new children, reusing fibers for matching keys and marking insertions, deletions, and moves with the appropriate flags.

completeWork — Preparing for Commit

completeWork runs as the work loop walks back up the tree. For host components (DOM elements), this is where:

  1. New instances are created via the host config's createInstance (for newly mounted elements)
  2. Update diffs are computed via prepareUpdate / prop diffing (for existing elements)
  3. Flags are bubbledsubtreeFlags on the parent gets the OR of all children's flags | subtreeFlags

The subtreeFlags bubbling is the mechanism that makes commit-phase traversal efficient. When completeWork finishes a fiber, it sets returnFiber.subtreeFlags |= completedWork.subtreeFlags | completedWork.flags. This means any ancestor can check whether any descendant has a particular effect without traversing the entire subtree.

commitRoot — Applying Changes

After the entire tree is rendered, commitRoot applies the changes to the host in three synchronous sub-phases, plus one asynchronous phase:

sequenceDiagram
    participant BM as Before Mutation
    participant M as Mutation
    participant L as Layout
    participant P as Passive (async)

    Note over BM: Read DOM before changes
    BM->>BM: getSnapshotBeforeUpdate<br/>View Transition snapshots
    Note over M: Apply DOM changes
    M->>M: insertions (Placement)<br/>updates (Update)<br/>deletions (ChildDeletion)<br/>ref detachment
    Note over L: After DOM mutation
    L->>L: useLayoutEffect callbacks<br/>componentDidMount/Update<br/>ref attachment
    Note over P: Asynchronous
    P->>P: useEffect cleanup<br/>useEffect callbacks

Each sub-phase uses the phase masks from Article 2 to filter which fibers need processing:

  • commitBeforeMutationEffects — Uses BeforeMutationMask (primarily Snapshot)
  • commitMutationEffects — Uses MutationMask (Placement, Update, ChildDeletion, Ref, Hydrating, Visibility)
  • commitLayoutEffects — Uses LayoutMask (Update, Callback, Ref, Visibility)
  • Passive effects — Uses PassiveMask (Passive, Visibility, ChildDeletion), scheduled via the Scheduler

After the mutation phase, root.current is swapped to point to the finished work tree. This means layout effects and refs see the new DOM, while before-mutation effects see the old DOM.

Tip: The three-phase commit design exists because different operations need to happen at different times relative to DOM mutation. getSnapshotBeforeUpdate must read the DOM before React changes it; componentDidMount must run after the DOM is updated; and useEffect can be deferred to avoid blocking the browser paint.

What's Next

We've now traced the complete render-commit cycle — from state update through lane assignment, scheduling, the work loop's tree traversal, and the commit phase's DOM mutations. In the next article, we'll zoom into what happens when beginWork encounters a function component: the hooks system, where useState, useEffect, and the dispatcher pattern give function components their state and side effects.