Must knowReact Internals · 04

React Fiber

Why the stack reconciler couldn’t pause, what a fiber holds, the child/sibling/return tree and double buffering.

This section:trigger→schedule→render→commit→effects
On this page · 7 sections
  1. Interview Q&A
  2. The problem: a call stack can’t be paused
  3. What a fiber holds
  4. The tree is a linked list
  5. Double buffering: current and work-in-progress
  6. Fiber, step by step
  7. Rules and caveats

Notes from Andrew Clark’s React Fiber Architecture, Lin Clark’s talk A Cartoon Intro to Fiber, and the FiberNode / createWorkInProgress code in the React 19.2.5 source. Part of React Internals. How React walks these fibers is covered in The Work Loop. My own notes and clarifications are marked with > 💬.

Interview Q&A

What is React Fiber?

Two things with one name. Fiber the architecture is the reconciler React has used since React 16, which replaced the old recursive “stack reconciler”. A fiber is a plain JavaScript object, one per component or host element instance, that holds everything React knows about that instance: its type, props, state, hooks, effects and its links to parent, child and sibling fibers.

Why did React need Fiber?

The stack reconciler rendered with recursive function calls. Once an update started, it ran until the whole tree was done, because you can’t pause the JavaScript call stack halfway and resume later. A large update blocked the main thread, dropping frames and input. Fiber reimplements the call stack as a linked list of objects React controls, so work can be paused, resumed, prioritized and thrown away.

What's the difference between an element, a component, a fiber and a DOM node?

An element is a disposable description returned by JSX. A component is your function. A fiber is React’s long-lived record of one mounted instance. A DOM node is what the host fiber creates. In our example, <Counter /> creates a new element every render, but there’s only ever one Counter fiber (plus its alternate) while it’s mounted.

What is double buffering in React?

React keeps up to two versions of each fiber: current (what’s on screen) and workInProgress (the version being rendered), linked to each other through alternate. Rendering builds the work-in-progress tree. The commit swaps a single pointer (root.current = finishedWork), and the old tree becomes the next render’s scratch space.

The problem: a call stack can’t be paused

Before Fiber, rendering looked roughly like this:

Stack reconciler (≤ React 15)
reconcile(button)
reconcile(Counter)
reconcile(main)
reconcile(App)

can’t pausecan’t prioritizecan’t throw away

Fiber (React 16+)
AppmainCounterbutton
workInProgress → Counter

pauseresumeprioritizediscard

The stack reconciler kept its place on the JS call stack. Fiber keeps it in one variable.
function reconcile(element) {
	const children = render(element)   // call the component
	children.forEach(reconcile)          // recurse
}

The state of “where are we in the tree” lived on the JavaScript call stack. That has three consequences:

Andrew Clark: “a fiber represents a unit of work”. A fiber is a virtual stack frame. The architecture doc: “The advantage of reimplementing the stack is that you can keep stack frames in memory and execute them however (and whenever) you want.” Because the “stack” is now heap objects, React’s loop can stop after any fiber, remember workInProgress (a single pointer), let the browser paint, and pick up exactly there.

What a fiber holds

The constructor from the 19.2.5 build, with profiler and dev-only fields removed:

FiberNode
Identity
tagkeyelementTypetypestateNode
Tree links
returnchildsiblingindexref
Props & state
pendingPropsmemoizedPropsmemoizedStateupdateQueuedependencies
Effects
flagssubtreeFlagsdeletions
Scheduling
laneschildLanes
Double buffering
alternate
A fiber is a plain object. You only need to remember: type, props, state, and links to parent / child / sibling.
function FiberNode(tag, pendingProps, key, mode) {
	this.tag = tag                  // what kind of fiber: FunctionComponent, HostComponent, …
	this.key = key
	this.elementType = null         // the element's type as written (e.g. the memo wrapper)
	this.type = null                // the resolved function/class, or 'div'
	this.stateNode = null           // DOM node for host fibers, class instance, or FiberRoot

	this.return = null              // parent
	this.child = null               // first child
	this.sibling = null             // next sibling
	this.index = 0                  // position among siblings (used by the list diff)

	this.ref = null
	this.pendingProps = pendingProps  // props for this render
	this.memoizedProps = null         // props used in the last completed render
	this.updateQueue = null           // effects list / class updates
	this.memoizedState = null         // function components: the first hook of the hooks list
	this.dependencies = null          // contexts this fiber reads

	this.mode = mode                  // ConcurrentMode, StrictMode bits
	this.flags = 0                    // side effects on this fiber (Placement, Update, …)
	this.subtreeFlags = 0             // OR of all flags below
	this.deletions = null             // children to delete in the commit

	this.lanes = 0                    // pending update priorities on THIS fiber
	this.childLanes = 0               // pending update priorities somewhere BELOW

	this.alternate = null             // the other version (current ↔ workInProgress)
}

Grouped by purpose:

Group Fields Used for
Identity tag, type, elementType, key reconciliation: “is this the same thing as last time?” (Reconciliation)
Tree links return, child, sibling, index walking the tree without recursion (The Work Loop)
Inputs pendingProps, memoizedProps bailout: if they’re the same object and nothing else changed, skip
State memoizedState, updateQueue, dependencies hooks list, effect list, context subscriptions (Hooks Under the Hood)
Output stateNode, flags, subtreeFlags, deletions what the commit must do (Commit Phase and Effects)
Scheduling lanes, childLanes which updates are pending here or below (Scheduler, Lanes and Batching)
Double buffering alternate pairing current and work-in-progress

Some common tag values: 0 FunctionComponent, 1 ClassComponent, 3 HostRoot, 5 HostComponent (div), 6 HostText, 7 Fragment, 11 ForwardRef, 14 MemoComponent, 15 SimpleMemoComponent, 13 SuspenseComponent.

Fibers are the “instances” of function components. A function component has no this. Its state lives on its fiber, in memoizedState. That’s why state survives exactly as long as the fiber does, and why Reconciliation decisions (“reuse this fiber or not”) decide whether state survives.

The tree is a linked list

A fiber doesn’t hold an array of children. It points to its first child, each child points to its next sibling, and every fiber points to its parent through return (named after the return address of a stack frame):

<App>
	<Header />
	<main>
		<Counter />
		<p>hi</p>
	</main>
</App>
HostRootAppHeadermainCounterp"hi" (HostText)
child (first child only)siblingreturn (parent)
No children arrays: each fiber points to its first child, its next sibling, and its parent via return.

This shape is what lets the work loop do a depth-first traversal with a while loop and one pointer instead of recursion.

Double buffering: current and work-in-progress

A fiber tree is never edited while it’s on screen. React keeps two trees:

Each fiber in one tree points to its counterpart in the other through alternate. When React starts on a fiber it calls createWorkInProgress(current, pendingProps), from the source:

function createWorkInProgress(current, pendingProps) {
	var workInProgress = current.alternate
	null === workInProgress
		? ((workInProgress = createFiber(current.tag, pendingProps, current.key, current.mode)),
			(workInProgress.elementType = current.elementType),
			(workInProgress.type = current.type),
			(workInProgress.stateNode = current.stateNode),
			(workInProgress.alternate = current),
			(current.alternate = workInProgress))
		: ((workInProgress.pendingProps = pendingProps),
			(workInProgress.type = current.type),
			(workInProgress.flags = 0),
			(workInProgress.subtreeFlags = 0),
			(workInProgress.deletions = null))
	// … then copy lanes, child, memoizedProps, memoizedState, updateQueue, sibling, index, ref
	return workInProgress
}

So after the first update, React recycles the fiber from two renders ago instead of allocating a new one. When the render completes, the commit does root.current = finishedWork, and the trees swap roles:

root.current → tree AA.alternate ↔ B
tree A
HostRoot
Countercount: 0
button"0"
tree B
HostRoot
Countercount: 0
button"0"
screen
<button>0</button>
  1. commit phaseroot.current = AAfter mount there is one tree, A. It is current: what the DOM reflects.stack:
  2. eventsetCount(1)Click. An update is queued on Counter’s hook. Nothing is rendered yet.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState
  3. render phasecreateWorkInProgress(A.root)Render starts by creating a work-in-progress copy of the root. The copies are linked to the originals through alternate.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › prepareFreshStack › createWorkInProgress
  4. render phasecreateWorkInProgress(A.counter) → Counter() → count = 1Counter runs on tree B: its hook says 1. Tree A still says 0, and A is what is on screen.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › updateReducer
  5. render phasereconcileChildren → useFiber(A.button) → completeWork: flag UpdateThe button fiber is copied too, with new props. If React abandoned this render now, it would just drop B: A was never touched.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › reconcileChildren › reconcileChildFibers › reconcileSingleElement › useFiber › createWorkInProgress
  6. commit phaseroot.current = finishedWorkCommit writes the DOM, then flips one pointer. B is now current; A becomes the spare.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects
  7. eventsetCount(2)Second click.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState
  8. render phasecreateWorkInProgress(B.counter) → reuses B.alternate (tree A)No new allocation this time: the work-in-progress is tree A, recycled. Counter runs on it: count 2.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › updateReducer
  9. commit phaseroot.current = finishedWorkCommit flips the pointer back to A. The two trees keep taking turns.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects
current stepcomponent runningcreatedupdated / movedcompleted

0 / 0
Double buffering: render builds the other tree off-screen; commit swaps a single pointer.

Why this matters for you:

Fiber, step by step

What exists at each moment for a Counter whose button is clicked:

Moment root.current Counter fibers memoizedState (the useState hook)
after mount tree A CounterA (alternate null) CounterA: 0
click → render starts tree A createWorkInProgress(CounterA) → new CounterB, CounterA.alternate = CounterB CounterB copies the hook list from A. The hook’s queue holds the pending update
Counter() runs tree A CounterB CounterB: 1. CounterA still 0, still on screen
commit tree B CounterB is current screen shows 1
next click tree B createWorkInProgress(CounterB) → reuses CounterA as the WIP CounterA becomes 2

Rules and caveats

Sources

Primary source: github.com/acdlite/react-fiber-architecture