The Fiber Data Structure
A fiber is a mutable, long-lived object for one instance of a component, host element or text node. It is one unit of work and also one node in a tree. Fibers are linked with child…
- Published
- Reading time
- 10 min read
Elements vs fibers vs DOM
"The virtual DOM" in the popular sense is closest to elements. The thing React actually diffs against, stores state in and schedules work on is the fiber tree. Fibers exist for components that produce no DOM (function components, context providers, Suspense, Fragments), and DOM nodes exist for only some fibers (HostComponent, HostText, ...).
The fields
The constructor initialises every field in a fixed order so V8 sees a single hidden class for all fibers. In DEV it then calls Object.preventExtensions(this) so nobody can add fields later.1 The Flow type describes each field.2
// packages/react-reconciler/src/ReactFiber.js (simplified)
function FiberNode(tag, pendingProps, key, mode) {
// Instance
this.tag = tag; this.key = key; this.elementType = null; this.type = null; this.stateNode = null;
// Fiber (tree links)
this.return = null; this.child = null; this.sibling = null; this.index = 0;
this.ref = null; this.refCleanup = null;
// Inputs / outputs
this.pendingProps = pendingProps; this.memoizedProps = null;
this.updateQueue = null; this.memoizedState = null; this.dependencies = null;
this.mode = mode;
// Effects
this.flags = NoFlags; this.subtreeFlags = NoFlags; this.deletions = null;
// Scheduling
this.lanes = NoLanes; this.childLanes = NoLanes;
this.alternate = null;
// profiler durations initialised to -0 / -1.1 (doubles) to avoid a V8 shape change
}Identity
tag: aWorkTagnumber saying what kind of fiber this is.beginWork/completeWorkswitch on it (list below).type: the resolved thing to render: the function or class, the host string'div', or a context object.elementTypeis the originalelement.typebefore resolution. They differ forlazy()(elementType is the lazy wrapper, type the resolved component),memo()and hot-reload proxies.key: copied from the element. Used only during child reconciliation (see beginWork & Reconciliation).stateNode: the "instance". The DOM element forHostComponent, the Text node forHostText, the class instance forClassComponent, the FiberRoot forHostRoot(see createRoot), andnullfor function components (their "instance" is the hook list inmemoizedState).
Tree links
return: the parent. It is called "return" and not "parent" because it is where the work loop returns to after finishing this fiber, like the return address on a call stack.child: the first child only.sibling: the next sibling. The children of a fiber are a singly linked list:parent.child → a.sibling → b.sibling → c.index: position among siblings. Used to detect moves during keyed reconciliation.
Inputs, outputs, state
pendingProps: the new props for this render (from the element).memoizedProps: props used the last time this fiber finished. IfoldProps === newProps(reference equality), there are no pending lanes and the context is unchanged, beginWork can bail out. This is why passing the same props object matters, and it is exactly what the compiler exploits.memoizedState: the committed state. Its shape depends ontag: for function components it's the head of the hook linked list (see Hooks on Mount), for classes it'sthis.state, and for HostRoot it's{element, isDehydrated, cache}.updateQueue: also tag-dependent. Class components and HostRoot use a circular update list (shared.pending). Function components use aFunctionComponentUpdateQueue(effect list, events, stores, and the compiler'smemoCache).3 Suspense boundaries keep their retry queue here. For HostComponents it is essentially unused in 19, because the props diff moved into commit (commitUpdatereceives old and new props).dependencies: the contexts this fiber read (firstContextlist) and their lanes, used for context propagation.mode: bitfield (ConcurrentMode,StrictLegacyMode,StrictEffectsMode,ProfileMode...) inherited from the parent at creation.4
Effects
flags: a bitmask of side effects this fiber needs in commit:Placement(insert),Update,ChildDeletion,Ref,Passive(hasuseEffect),Callback, ...5subtreeFlags: the OR of all descendants' flags, bubbled up incompleteWork. The commit phase skips any subtree whosesubtreeFlagslacks the bits it cares about. This replaced the old "effect list" (nextEffectchain) in React 18.deletions: children to delete, stored on the parent because the deleted fibers are no longer in the new child list.
Scheduling
lanes: lanes with pending work on this fiber itself (asetStateon it).childLanes: the OR of lanes pending anywhere below it. Set bymarkUpdateLaneFromFiberToRootwhen an update is queued.6 It lets beginWork skip whole subtrees in O(1): if(childLanes & renderLanes) === 0, there's nothing to do down there.
Double buffering
alternate: the other version of this fiber.current.alternate === wipandwip.alternate === current.
WorkTag list
(2 and 20 are unused; 21 is ScopeComponent.)
Walking the tree without recursion
flowchart TD
R["HostRoot"] -->|child| A["App"]
A -->|child| H["header"]
H -->|sibling| M["main"]
M -->|sibling| F["footer"]
H -.->|return| A
M -.->|return| A
F -.->|return| A
M -->|child| L["List"]
L -.->|return| M
A -.->|return| RThe work loop is a depth-first walk done by hand:
// conceptual; real code in performUnitOfWork / completeUnitOfWork
let node = root;
while (node) {
const next = beginWork(node); // returns node.child or null
if (next) { node = next; continue; }
while (node) { // no child: complete and go to sibling, else return up
completeWork(node);
if (node.sibling) { node = node.sibling; break; }
node = node.return;
}
}The walk visits nodes in this order: begin App → begin header → complete header → begin main → begin List → complete List → complete main → begin footer → complete footer → complete App. See Work Loop for the real performUnitOfWork.10
Why not recursion? Before 16, the "stack reconciler" recursed through render() calls on the JS call stack. A deep tree meant a long, unbreakable synchronous chunk, because JS cannot pause a call stack and resume it later. Fibers move that stack onto the heap:
- Pause/resume. The loop's only state is the module variable
workInProgress. To yield, return from the loop. To resume, call it again (Lanes & Scheduler). - Abort. To throw away a render, drop the WIP pointer. Nothing lives on the call stack.
- Prioritise.
childLaneslets the loop skip subtrees without visiting them. - No stack overflow on very deep trees.
- Error and Suspense unwinding. "Throwing" to the nearest boundary is a walk up
returnpointers (unwindWork), not a JS exception crossing arbitrary frames.
The cost is memory: each fiber is a fairly big object (about 20+ fields, more in DEV), and there are two of them per instance.
Double buffering: current and workInProgress
At most two fibers exist per mounted instance:
- current: what's committed and on screen.
FiberRoot.currentpoints to its HostRoot. - workInProgress (WIP): the version being rendered. It is built from
currentplus updates.
On commit, root.current = finishedWork. The WIP tree becomes current, and the old current becomes the pool for the next WIP.11 The comment explains the placement: after mutation (so componentWillUnmount sees the old tree) but before layout (so componentDidMount sees the new one).
createWorkInProgress
// packages/react-reconciler/src/ReactFiber.js (simplified)
export function createWorkInProgress(current, pendingProps) {
let workInProgress = current.alternate;
if (workInProgress === null) {
// "We use a double buffering pooling technique because we know that we'll only ever need at
// most two versions of a tree... This is lazily created to avoid allocating extra objects for
// things that are never updated."
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;
} else {
workInProgress.pendingProps = pendingProps;
workInProgress.type = current.type;
workInProgress.flags = NoFlags; // reset effects from the last time this object was WIP
workInProgress.subtreeFlags = NoFlags;
workInProgress.deletions = null;
}
workInProgress.flags = current.flags & StaticMask; // keep only "static" flags (e.g. has passive effect)
workInProgress.childLanes = current.childLanes;
workInProgress.lanes = current.lanes;
workInProgress.child = current.child; // shares current's children until reconciled
workInProgress.memoizedProps = current.memoizedProps;
workInProgress.memoizedState = current.memoizedState;
workInProgress.updateQueue = current.updateQueue;
workInProgress.dependencies = clone(current.dependencies); // mutated during render, can't share
workInProgress.sibling = current.sibling; workInProgress.index = current.index;
workInProgress.ref = current.ref; workInProgress.refCleanup = current.refCleanup;
return workInProgress;
}12 Things to notice:
- Allocation is lazy. On the first render there are no alternates at all. Mount creates fibers directly, and the HostRoot gets its alternate in
prepareFreshStack.13 The second render allocates alternates. From the third render on, objects are recycled. - WIP shares data with current (
memoizedState,updateQueue,child) until the render overwrites it. Hooks clone their hook objects as they process them. The update queue is designed so both versions can point to the sameshared.pending, which is why updates aren't lost when a render is thrown away. StaticMaskflags (likeLayoutStatic/PassiveStatic, "this fiber has effects at all") survive resets, so unmount knows what cleanup to run even if this render didn't schedule effects.14stateNodeis shared: both versions point to the same DOM node or class instance. There's only one DOM.
flowchart LR
FR["FiberRoot"] -->|current| C0["HostRoot (A)"]
C0 <-->|alternate| W0["HostRoot (B) = WIP"]
C0 -->|child| C1["App (A)"]
W0 -->|child| W1["App (B)"]
C1 <-->|alternate| W1
C1 -->|stateNode null| N1[ ]
C1 -->|child| C2["div (A)"]
W1 -->|child| W2["div (B)"]
C2 <-->|alternate| W2
C2 -->|stateNode| D["<div> (single DOM node)"]
W2 -->|stateNode| DAfter commit, FR.current points to HostRoot (B), and (A) becomes the scratch space for the next render.
Common misconceptions
- "React re-creates the tree every render." Elements are re-created. Fibers are reused through
alternate, and unchanged subtrees are cloned shallowly or skipped entirely (bailoutOnAlreadyFinishedWork), so their WIP is never built. - "Every render touches every fiber." With
childLanes, asetStatedeep in the tree visits only the path from the root down to the updated fiber, and then whatever it re-renders. - "StrictMode doubles the fibers." It doesn't. StrictMode in DEV calls component functions twice on the same WIP fiber (see Bootstrap), and it double-invokes effects at mount on the same fibers. The tree shape is identical.
- "Fibers are a React 18 concurrency feature." Fibers have existed since React 16 (2017). React 18/19 just added the lanes and schedulers that use them.
With the React Compiler
The fiber structure is identical. The compiler changes what goes into fibers:
- Compiled components store the
useMemoCachearray onfiber.updateQueue.memoCache(see React Compiler). That is one extra array per compiled component, and it is preserved across thecurrent↔ WIP clone becauseupdateQueueis shared. - Because compiled code returns the same element objects when inputs haven't changed, the child fiber's
pendingProps === memoizedPropsholds, and beginWork bails out at that fiber without calling the component. That reference-equality check on fiber props is where the compiler's performance win actually shows up. See With vs Without Compiler.
Summary
Elements are throwaway descriptions; the fiber is what React keeps. One mutable fiber exists per mounted component, host element or text node: a unit of work and a tree node in one. child, sibling and return pointers let a loop walk the tree and stop at any node instead of recursing. Each fiber has an alternate, and the current and workInProgress trees recycle each other's objects, so a commit is one pointer swap. Next: the work loop.
Key takeaways
- Elements vs fibers: elements are recreated every render; fibers carry identity, state and scheduling while mounted.
tag,type,stateNode: the kind of fiber, what it renders, and its instance (nullfor function components).child/sibling/return: linked children plus a parent pointer, so the loop can pause, abort and resume with no call stack.pendingPropsvsmemoizedProps: reference-equal props, no pending lanes and unchanged context let beginWork bail out.memoizedStateandupdateQueue: tag-dependent; the hook list for function components,{element, isDehydrated, cache}for the HostRoot.flagsandsubtreeFlags: side effects for commit, bubbled up so commit skips clean subtrees.lanesandchildLanes: pending work on the fiber and below it, so beginWork skips whole subtrees in O(1).createWorkInProgress: lazily allocates the alternate, then reuses it, sharing data withcurrentuntil the render overwrites it.- Two objects per instance: which fiber is current flips each commit, so never assume you hold the current one.
Footnotes
-
FiberNodeconstructor — packages/react-reconciler/src/ReactFiber.js#L134-L207 (opens in a new tab) ↩ -
Fibertype — packages/react-reconciler/src/ReactInternalTypes.js#L89-L210 (opens in a new tab) ↩ -
FunctionComponentUpdateQueue.memoCache— packages/react-reconciler/src/ReactFiberHooks.js#L249-L254 (opens in a new tab) ↩ -
TypeOfModebits — packages/react-reconciler/src/ReactTypeOfMode.js#L10-L21 (opens in a new tab) ↩ -
fiber flags (
Placement,Update,ChildDeletion,Callback,Ref,Passive) — packages/react-reconciler/src/ReactFiberFlags.js#L18-L36 (opens in a new tab) ↩ -
markUpdateLaneFromFiberToRootsetschildLanes— packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L189-L208 (opens in a new tab) ↩ -
shouldConstruct/isSimpleFunctionComponent— packages/react-reconciler/src/ReactFiber.js#L303-L314 (opens in a new tab) ↩ -
updateMemoComponentupgrades toSimpleMemoComponent— packages/react-reconciler/src/ReactFiberBeginWork.js#L486-L497 (opens in a new tab) ↩ -
performUnitOfWork— packages/react-reconciler/src/ReactFiberWorkLoop.js#L3081 (opens in a new tab) ↩ -
root.current = finishedWork— packages/react-reconciler/src/ReactFiberWorkLoop.js#L4047-L4051 (opens in a new tab) ↩ -
createWorkInProgress— packages/react-reconciler/src/ReactFiber.js#L322-L437 (opens in a new tab) ↩ -
prepareFreshStack→createWorkInProgress(root.current, null)— packages/react-reconciler/src/ReactFiberWorkLoop.js#L2254-L2258 (opens in a new tab) ↩ -
LayoutStaticandStaticMask— packages/react-reconciler/src/ReactFiberFlags.js#L73 (opens in a new tab), #L141 (opens in a new tab) ↩