Good to knowReact Internals · 07

The Work Loop

beginWork / completeWork, depth-first traversal without recursion, and bailouts.

This section:trigger→schedule→render→commit→effects
On this page · 6 sections
  1. Interview Q&A
  2. The problem: walking a tree without the call stack
  3. One unit of work: begin, then maybe complete
  4. Bailouts: how React skips work
  5. The work loop, step by step
  6. Rules and caveats

How React walks the fiber tree during the render phase and decides which components to skip. Read from workLoopSync, workLoopConcurrentByScheduler, performUnitOfWork, completeUnitOfWork, beginWork and bailoutOnAlreadyFinishedWork in the React 19.2.5 build. Part of React Internals. It builds on React Fiber and explains the mechanism behind the React Performance module’s optimizations. My own notes and clarifications are marked with > 💬.

Interview Q&A

How does React traverse the component tree?

Depth-first, with a loop instead of recursion. A single pointer, workInProgress, says which fiber to process next. beginWork handles a fiber on the way down (render it, reconcile its children, return the first child). When a fiber has no children, completeWork runs on the way up, then React moves to the sibling, or back to the return (parent) when there are no more siblings.

What does "bailout" mean?

React skipping work for a fiber because it can prove nothing changed: the same props object, no pending update on it, and no context change. If nothing below it has pending work either (childLanes), React skips the entire subtree in one step. In our example, when only Counter’s state changes, App and Header are never called.

Why does a child re-render when its parent re-renders, even with the same props?

Because the parent’s render creates a new element, so the child gets a new props object. React compares props with !==, not deeply, so oldProps !== newProps and the child renders. memo replaces that check with a shallow comparison of each prop. Passing the element in from above (children) keeps the same props object, so React bails out without memo.

How can rendering be interrupted?

The concurrent loop checks shouldYield() between units of work. If ~5ms have passed, it returns, and the workInProgress pointer remembers where to resume. The sync loop never checks.

The problem: walking a tree without the call stack

Recursion is the natural way to walk a tree, but recursion keeps its position on the JS call stack (React Fiber explains why that’s a problem). Fiber stores the position in a variable instead. The whole render phase is these few lines:

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

function workLoopConcurrentByScheduler() {
	while (workInProgress !== null && !shouldYield()) performUnitOfWork(workInProgress)
}

The only difference between synchronous and concurrent rendering is the !shouldYield() check. Which loop runs depends on the update’s priority. See Scheduler, Lanes and Batching.

One unit of work: begin, then maybe complete

From the source, with profiling removed:

yes → go DOWN: workInProgress = childnoyes → go ACROSS to siblingno → go UPno → complete the parent (bubble flags)yesperformUnitOfWorkone fiberbeginWork ↓call it, or bail outchild?completeWork ↑create / flag DOMsibling?past root?commit
Down with beginWork, across to siblings, up with completeWork. No recursion: just one workInProgress pointer.
function performUnitOfWork(unitOfWork) {
	const current = unitOfWork.alternate
	const next = beginWork(current, unitOfWork, entangledRenderLanes)  // go DOWN
	unitOfWork.memoizedProps = unitOfWork.pendingProps
	if (next === null) {
		completeUnitOfWork(unitOfWork)   // no child → finish this one and climb
	} else {
		workInProgress = next            // descend into the first child
	}
}

function completeUnitOfWork(unitOfWork) {
	let completedWork = unitOfWork
	do {
		const returnFiber = completedWork.return
		const next = completeWork(completedWork.alternate, completedWork, entangledRenderLanes)
		if (next !== null) { workInProgress = next; return }   // rare: completeWork spawned more work
		const sibling = completedWork.sibling
		if (sibling !== null) { workInProgress = sibling; return }  // go ACROSS
		completedWork = returnFiber                                   // go UP
		workInProgress = completedWork
	} while (completedWork !== null)
	workInProgressRootExitStatus = RootCompleted   // climbed past the root: tree is done
}

beginWork and completeWork split the per-fiber work:

beginWork (down) completeWork (up)
Function component try to bail out, or call it (with hooks), then reconcile the returned children nothing much
Host component (div) reconcile props.children mount: create the DOM node (document.createElement), append the already-completed child DOM nodes, set initial props. update: if memoizedProps !== newProps, flag Update. In React 19 the per-attribute diff happens later, in the commit (commitUpdate)
Every fiber – bubble: subtreeFlags |= child.flags | child.subtreeFlags so the commit can skip clean subtrees
Returns first child fiber, or null usually null

On mount, the DOM tree is built bottom-up, detached from the document, during completeWork. The commit then inserts only the top new node. That’s one appendChild for a whole new subtree instead of one per node.

Bailouts: how React skips work

This is where all performance tricks end up. The start of beginWork, from the 19.2.5 source:

if (current !== null) {
	if (current.memoizedProps !== workInProgress.pendingProps ||
	    workInProgress.type !== current.type) {
		didReceiveUpdate = true                          // props object changed → must render
	} else {
		if (!checkScheduledUpdateOrContext(current, renderLanes) &&
		    (workInProgress.flags & DidCapture) === 0) {
			didReceiveUpdate = false
			return attemptEarlyBailoutIfNoScheduledUpdate(current, workInProgress, renderLanes)
		}
		// ...
	}
}

checkScheduledUpdateOrContext returns true if the fiber itself has an update in the lanes being rendered, or a context it reads has changed:

function checkScheduledUpdateOrContext(current, renderLanes) {
	if ((current.lanes & renderLanes) !== 0) return true
	const dependencies = current.dependencies
	return dependencies !== null && checkIfContextChanged(dependencies)
}

If the fiber can bail out, bailoutOnAlreadyFinishedWork decides how much to skip:

function bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) {
	if ((renderLanes & workInProgress.childLanes) === 0) {
		// (after checking for context changes propagated from above)
		return null                // NOTHING below needs work → skip the whole subtree
	}
	// something below does → clone the children (createWorkInProgress on each) and keep going
	return workInProgress.child
}

So there are three outcomes for each fiber:

Case Condition What happens
Render new props object, or own state update, or context changed the component function runs, and children are reconciled
Bail out, continue nothing changed here, but childLanes has work the component is not called. Its children are cloned and the walk continues down
Bail out, skip subtree nothing changed here or below beginWork returns null. The whole subtree is skipped in O(1)

childLanes is set for every queued update just before the render starts (prepareFreshStack → finishQueueingConcurrentUpdates → markUpdateLaneFromFiberToRoot). That ORs the lane into fiber.lanes and into childLanes of every ancestor up to the root. That trail of breadcrumbs is how React finds the one changed component without calling every component above it.

Where memo fits

A memo component whose parent re-rendered does get a new props object, so the !== check above says “changed”. updateMemoComponent / updateSimpleMemoComponent then run a second check, shallowEqual(prevProps, nextProps) (or your arePropsEqual), and bail out if it passes. See Memoize Components.

A second chance after rendering

Even when a function component does run, if its hooks produced no change (the state updates all computed to Object.is-equal values), then didReceiveUpdate stays false and React calls bailoutHooks + bailoutOnAlreadyFinishedWork, discarding the output and skipping the children. This is why a component that sets the same state can render once and still not re-render its children.

The work loop, step by step

function App() {
	return (
		<>
			<Header />
			<Counter />
		</>
	)
}
function Counter() {
	const [n, setN] = useState(0)
	return <button onClick={() => setN(n + 1)}>{n}</button>
}
HostRoot
App
Fragment
Header
Counter
button
"1"
  1. render phasebeginWork(HostRoot)No own update, but childLanes has Sync → clone the children and continue.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(HostRoot) › bailoutOnAlreadyFinishedWork
  2. render phasebeginWork(App)Same props object, no update, childLanes has Sync → bail out, continue. App() is not called.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › bailoutOnAlreadyFinishedWork
  3. render phasebeginWork(Fragment)Same → bail out, continue down.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Fragment) › bailoutOnAlreadyFinishedWork
  4. render phasebeginWork(Header) → nullSame props, no update, childLanes = 0 → skip the whole subtree.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Header) › bailoutOnAlreadyFinishedWork
  5. render phasecompleteUnitOfWork(Header) → siblingNothing to complete. Go across to the sibling.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork
  6. render phasebeginWork(Counter) → Counter()lanes has Sync → render: Counter() runs, useState → 1, reconcile <button> → reuse its fiber.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter()
  7. render phasebeginWork(button)New props object → reconcile its text child "1". Go down.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(button) › reconcileChildren › reconcileChildFibers
  8. render phasebeginWork(text) → null · completeWork(text)No children. Text changed → flag Update. Climb up.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork("1")
  9. render phasecompleteWork(button)New props → flag Update. Bubble subtreeFlags to the parent.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(button)
  10. render phasecompleteWork × 4 → workInProgress = nullBubble up to the root. Done: only Counter() ran, and the commit will only visit the flagged path.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(HostRoot)
current stepcomponent runningbailed outskippedcompleted

0 / 0
The work loop for one setN(1): down with beginWork, across, and up with completeWork.

The fiber tree is HostRoot → App → Fragment → [Header, Counter → button → "0"]. The button is clicked and setN(1) queues an update. When the render starts, Counter.lanes gets SyncLane, and so do the childLanes of Fragment, App and HostRoot.

# workInProgress Call Decision Next
1 HostRoot beginWork no own update, childLanes has Sync → clone children App
2 App beginWork same props object (null props, cloned), no update, childLanes has Sync → bail out, continue (App not called) Fragment
3 Fragment beginWork same → bail out, continue Header
4 Header beginWork same props, no update, childLanes = 0 → skip subtree → null –
5 Header completeUnitOfWork nothing to complete → sibling Counter
6 Counter beginWork lanes has Sync → render: Counter() runs, useState → 1, reconcile <button> → reuse fiber, new props button
7 button beginWork new props object → reconcile its text child "1" text
8 text beginWork → null, then completeWork text changed → flag Update back up
9 button completeWork new props object → flag Update (the commit will find that only onClick changed, which React stores on the node rather than as an attribute). Bubble subtreeFlags up
10 Counter → Fragment → App → HostRoot completeWork each bubble subtreeFlags done

Result: only Counter() ran. The commit walks down only where subtreeFlags is set and updates one text node (and the button’s stored props).

Step 2 is the answer to “why doesn’t App re-render when Counter’s state changes?” Updates start at the component that owns the state, and everything above it bails out.

Yielding in the concurrent loop

If this update were a transition, the loop would be workLoopConcurrentByScheduler. After each performUnitOfWork, shouldYield() checks whether the current slice has used ~5ms. If so, the loop exits, workInProgress still points at, say, the button fiber, and React asks the Scheduler to call it back. The next slice resumes at exactly that fiber. If a higher-priority update arrived in between, React instead throws the work-in-progress away (prepareFreshStack) and starts over from the root with the new lanes.

Rules and caveats

Sources

Primary source: github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberWorkLoop.js