Inside React's FiberWorkLoop
In the previous article, I traced setState all the way to pixels on screen. That tour collapsed everything React does into two bullets: render phase and commit phase. This article zooms inside those bullets, following the numbered diagram below. Four stages, in order.
The reconciler workflow in four numbered stages. Credit: 7km.top reconciler workflow.
Stage 1. Input
You call setState somewhere. React does not render yet. It calls scheduleUpdateOnFiber, which is the single entry point for every state update. Hooks setState, class this.setState, useReducer dispatches, they all land here.
Its job: mark the affected Fiber node as dirty and pass the ball. Nothing visible changes.
Stage 2. Register task
Next is ensureRootIsScheduled. The name says it: make sure the root has something scheduled. Three things happen:
- Check if there are pending updates.
- Compute their priority: Sync, Default, Transition, or Idle.
- Schedule the work, and here the path forks. Sync-priority work goes on a synchronous queue flushed in a microtask, so it never enters Scheduler's task heap. Everything else is handed to Scheduler as a task, to run when there is time.
Scheduler is a separate npm package (scheduler), not React itself. Its only job is deciding when to run tasks, using MessageChannel and time slicing. It has its own internal workloop (bottom right box in the diagram, workLoop 任务调度). That inner loop schedules tasks. It does not construct fibers.
At this point, React still has not done any render work. It has only queued the update.
Stage 3. Fiber tree construction
Eventually Scheduler wakes React up: your turn, you have 5ms. The blue arrow labeled 执行 task 回调 / fiber 树构造 points from Scheduler up into the yellow ReactFiberWorkLoop box.
Depending on priority, React enters one of two entry points:
performSyncWorkOnRoot: cannot be interrupted. Only Sync priority lands here: updates from discrete events (a click) andflushSync.performConcurrentWorkOnRoot: can be interrupted. Everything else lands here, including Default priority (asetStatefrom a timeout or a resolved fetch),startTransition,useDeferredValue, and Idle work. Sync is the only path that runs start to finish without yielding.
Both reach the same inner workloop:
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress)
}The loop is a depth-first walk of the tree, and each node is visited twice:
beginWorkon the way down: run the component function, produce children, descend to the first child.completeWorkon the way up: once a node runs out of children, finish it. If it is a host element (div,span), create its DOM instance in memory and bubble its flags up (which changes need committing). Then move to the sibling, or keep climbing.
Going down builds the structure; coming up assembles it, because a parent's DOM can only be put together once its children exist. Note that performUnitOfWork itself runs beginWork; the upward completeWork pass is driven by a separate completeUnitOfWork, triggered when a node has no child.
The walk is iterative, not recursive, and that is the whole point of Fiber. Each node carries child / sibling / return pointers, and the loop follows them by hand. Because the current position lives in the workInProgress variable, not in the JS call stack, React can stop anywhere: in Concurrent mode it calls shouldYield() between units, and when its ~5ms slice runs out it hands control back to the browser, then resumes from the same node next tick. The old recursive reconciler could not do this, because a call stack cannot be paused and stored. That is how React 18 can interrupt a render.
When the loop exits (whole tree done), workInProgress is ready and all effects are collected.
Stage 4. Output
Fiber tree built. React enters commitRoot. This stage cannot be interrupted.
commitRoot runs in three internal stages (before mutation, mutation, layout) and applies the collected effects to the real DOM. The green arrow labeled 输出 points from commitRoot to react-dom, the renderer package.
react-dom translates effects into real DOM operations: insertBefore, removeChild, setAttribute. After this, the DOM has actually changed, and the browser's Style Recalc, Layout, Paint, and Composite pipeline takes over.
Four stages at a glance
| Stage | Function | Work | Interruptible |
|---|---|---|---|
| 1. Input | scheduleUpdateOnFiber | Mark fiber dirty | instant |
| 2. Register | ensureRootIsScheduled | Compute priority, register task | instant |
| 3. Construct | performUnitOfWork (beginWork, completeWork) | Build fiber tree, diff | yes in Concurrent |
| 4. Output | commitRoot | Apply effects to DOM | no |
Not every node re-renders
Rebuilding the tree on every update, and again from the root after an interrupt, sounds expensive. It is cheap, because most nodes bail out.
When beginWork hits a fiber with the same props as last render and no pending update of its own, React skips re-running it, then checks the subtree:
- Nothing below has work either? Skip the whole subtree. Not one component inside runs.
- A descendant has work? Skip this one, but walk down to reach it.
Each fiber's childLanes records whether anything below is dirty, so React starts from the root yet walks only the paths that changed. That is why restarting from the root stays cheap.
You steer this: React.memo lets a child bail even when its parent re-ran (shallow prop compare); useMemo and useCallback keep the values you pass down referentially stable so that compare passes; a setState to an Object.is-equal value bails out before it even schedules.
Three independent concerns
The diagram separates three things that are easy to conflate:
- Scheduler decides when to run.
- ReactFiberWorkLoop decides how to run.
- react-dom decides where it lands.
Each lives in its own npm package. Swap the last one and you get react-native, react-three-fiber, or ink. The middle and bottom stay the same.
Back to the surface
This whole tour lived in two packages you never import: react-reconciler (the workloop, stages 1 through 4) and scheduler (priority and the 5ms slicing). Both get compiled into react-dom and shipped without a name you would go looking for.
The four packages and how they talk to each other. Credit: 7km.top 宏观包结构.
They matter because of the surface you do touch. Four packages, two layers:
| Package | You import it? | Role |
|---|---|---|
react | yes, every day | the component API: useState, useMemo, startTransition |
react-dom | yes | the entry point and the DOM output: createRoot, flushSync |
react-reconciler | no | the workloop: how the tree is built and committed |
scheduler | no | when that work runs, and for how long |
Every lever you already know reaches two layers down. startTransition picks the concurrent path. React.memo feeds the bailout check. The 5ms you never see is why a long render will not freeze the tab. The surface stays small on purpose. These four stages are what it rests on.
Related: from setState to pixels for the macro journey, and Things, elements, fibers, pixels for how ReactNode, ReactElement, Component, and div actually relate to each other.
The two diagrams come from 7km.top's reconciler workflow and macro package structure pages. I highly recommend the full series for function level walkthroughs of the React source.