Notes from Andrew Clark’s React Fiber Architecture, Lin Clark’s talk A Cartoon Intro to Fiber, and the
FiberNode/createWorkInProgresscode 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)
can’t pausecan’t prioritizecan’t throw away
Fiber (React 16+)
workInProgress → Counterpauseresumeprioritizediscard
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:
- It can’t be interrupted. To yield to the browser you’d have to return from every frame, losing your place.
- It can’t be prioritized. A keystroke arriving mid-render waits for the whole tree.
- It can’t be thrown away cleanly if the result is already stale.
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:
FiberNodeIdentity
tagkeyelementTypetypestateNodeTree links
returnchildsiblingindexrefProps & state
pendingPropsmemoizedPropsmemoizedStateupdateQueuedependenciesEffects
flagssubtreeFlagsdeletionsScheduling
laneschildLanesDouble buffering
alternatefunction 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, inmemoizedState. 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>
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:
- current:
root.current, what the DOM reflects right now. - workInProgress: the tree being rendered.
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:
- commit phase
root.current = AAfter mount there is one tree, A. It iscurrent: what the DOM reflects.stack: - event
setCount(1)Click. An update is queued on Counter’s hook. Nothing is rendered yet.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState - render phase
createWorkInProgress(A.root)Render starts by creating a work-in-progress copy of the root. The copies are linked to the originals throughalternate.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › prepareFreshStack › createWorkInProgress - render phase
createWorkInProgress(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 - render phase
reconcileChildren → 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 - commit phase
root.current = finishedWorkCommit writes the DOM, then flips one pointer. B is now current; A becomes the spare.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects - event
setCount(2)Second click.stack: dispatchDiscreteEvent › dispatchEvent › onClick › dispatchSetState - render phase
createWorkInProgress(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 - commit phase
root.current = finishedWorkCommit flips the pointer back to A. The two trees keep taking turns.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects
Why this matters for you:
- Interruptible rendering is safe. Abandoning a render just drops the work-in-progress pointers. The current tree was never touched.
- State reads are consistent. A render reads from the work-in-progress
hooks, which start as copies of
current’s. Nothing leaks to the screen until commit. - “Tearing” is only possible with outside stores. React’s own state is
snapshotted per render. External mutable stores need
useSyncExternalStore(useSyncExternalStore) to stay consistent across a render that yields.
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
- Fibers are internal. Never rely on fields like
_reactInternalsor__reactFiber$…on DOM nodes. They’re for DevTools and change between versions. - One fiber = one mounted instance. Rendering the same component in two places makes two fibers with independent state.
- Unmounted means the fiber is gone. Its hooks, and therefore its state, are garbage.
- “Fiber” doesn’t mean faster by default. It makes scheduling possible. Most updates (clicks, typing) still render synchronously. Only transitions and deferred values are time-sliced. See Scheduler, Lanes and Batching.
Sources
- Andrew Clark: React Fiber Architecture
- Lin Clark: A Cartoon Intro to Fiber (React Conf 2017)
- React 19.2.5 source:
FiberNode,createFiber,createWorkInProgress, androot.current = finishedWorkinflushMutationEffects
Primary source: github.com/acdlite/react-fiber-architecture