How React walks the fiber tree during the render phase and decides which components to skip. Read from
workLoopSync,workLoopConcurrentByScheduler,performUnitOfWork,completeUnitOfWork,beginWorkandbailoutOnAlreadyFinishedWorkin 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:
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 oneappendChildfor 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>
}
- render phase
beginWork(HostRoot)No own update, butchildLaneshas Sync → clone the children and continue.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(HostRoot) › bailoutOnAlreadyFinishedWork - render phase
beginWork(App)Same props object, no update,childLaneshas Sync → bail out, continue.App()is not called.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › bailoutOnAlreadyFinishedWork - render phase
beginWork(Fragment)Same → bail out, continue down.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Fragment) › bailoutOnAlreadyFinishedWork - render phase
beginWork(Header) → nullSame props, no update,childLanes = 0→ skip the whole subtree.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Header) › bailoutOnAlreadyFinishedWork - render phase
completeUnitOfWork(Header) → siblingNothing to complete. Go across to the sibling.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork - render phase
beginWork(Counter) → Counter()laneshas Sync → render:Counter()runs,useState→ 1, reconcile<button>→ reuse its fiber.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() - render phase
beginWork(button)New props object → reconcile its text child"1". Go down.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(button) › reconcileChildren › reconcileChildFibers - render phase
beginWork(text) → null · completeWork(text)No children. Text changed → flagUpdate. Climb up.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork("1") - render phase
completeWork(button)New props → flagUpdate. BubblesubtreeFlagsto the parent.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(button) - render phase
completeWork × 4 → workInProgress = nullBubble up to the root. Done: onlyCounter()ran, and the commit will only visit the flagged path.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(HostRoot)
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
Appre-render whenCounter’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
- Updates start where the state lives. Keep state as low as possible so fewer components sit between it and the fibers that read it.
- Props are compared by reference. A new JSX element means a new props
object, which means a render unless
memosays otherwise. - Reusing an element skips it for free.
childrenpassed from a parent that didn’t re-render carry the same props object, so they bail out. This is the basis of Element Optimization and Provider Component. - Context bypasses bailouts. A fiber that reads a changed context renders even if its parent bailed out and its props are identical (Optimize Context).
- Rendering ≠ committing. The loop can run a component and then discard the result (bailout after render, abandoned transitions).
Sources
- React 19.2.5 source:
workLoopSync,workLoopConcurrentByScheduler,performUnitOfWork,completeUnitOfWork,beginWork,checkScheduledUpdateOrContext,bailoutOnAlreadyFinishedWork,markUpdateLaneFromFiberToRoot - React source:
ReactFiberWorkLoop.jsandReactFiberBeginWork.js - Andrew Clark: React Fiber Architecture
Primary source: github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberWorkLoop.js