Inside React's FiberWorkLoop

2026/04/20
reactinternalsfiber

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.

React FiberWorkLoop 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:

  1. Check if there are pending updates.
  2. Compute their priority: Sync, Default, Transition, or Idle.
  3. 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) and flushSync.
  • performConcurrentWorkOnRoot: can be interrupted. Everything else lands here, including Default priority (a setState from 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:

  • beginWork on the way down: run the component function, produce children, descend to the first child.
  • completeWork on 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

StageFunctionWorkInterruptible
1. InputscheduleUpdateOnFiberMark fiber dirtyinstant
2. RegisterensureRootIsScheduledCompute priority, register taskinstant
3. ConstructperformUnitOfWork (beginWork, completeWork)Build fiber tree, diffyes in Concurrent
4. OutputcommitRootApply effects to DOMno

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.

React core packages 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:

PackageYou import it?Role
reactyes, every daythe component API: useState, useMemo, startTransition
react-domyesthe entry point and the DOM output: createRoot, flushSync
react-reconcilernothe workloop: how the tree is built and committed
schedulernowhen 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.