The Work Loop — How React Renders
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 timetimerQueue: 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.
beginWorkdescends into children;completeUnitOfWorkhandles 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:
FunctionComponent→updateFunctionComponent→ callsrenderWithHooks(invokes your component function)HostComponent→updateHostComponent→ reconciles childrenSuspenseComponent→updateSuspenseComponent→ manages fallback/content switchingMemoComponent→updateMemoComponent→ 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:
- New instances are created via the host config's
createInstance(for newly mounted elements) - Update diffs are computed via
prepareUpdate/ prop diffing (for existing elements) - Flags are bubbled —
subtreeFlagson the parent gets the OR of all children'sflags | 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— UsesBeforeMutationMask(primarilySnapshot)commitMutationEffects— UsesMutationMask(Placement, Update, ChildDeletion, Ref, Hydrating, Visibility)commitLayoutEffects— UsesLayoutMask(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.
getSnapshotBeforeUpdatemust read the DOM before React changes it;componentDidMountmust run after the DOM is updated; anduseEffectcan 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.