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
React InternalsPart 05 · 6 of 18

Elements vs fibers vs DOM

ElementFiberDOM node
Created byjsx() every renderreconciler, once per mounted instance (+1 alternate)createInstance in completeWork
Lifetimeone render (garbage)as long as the component is mountedas long as the fiber
Mutableno (frozen in DEV)yes, heavilyyes
Holdstype, key, propseverything: state, hooks, effects, lanes, linksthe actual pixels

"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

JavaScript
// 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: a WorkTag number saying what kind of fiber this is. beginWork/completeWork switch on it (list below).
  • type: the resolved thing to render: the function or class, the host string 'div', or a context object. elementType is the original element.type before resolution. They differ for lazy() (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 for HostComponent, the Text node for HostText, the class instance for ClassComponent, the FiberRoot for HostRoot (see createRoot), and null for function components (their "instance" is the hook list in memoizedState).
  • 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. If oldProps === 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 on tag: for function components it's the head of the hook linked list (see Hooks on Mount), for classes it's this.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 a FunctionComponentUpdateQueue (effect list, events, stores, and the compiler's memoCache).3 Suspense boundaries keep their retry queue here. For HostComponents it is essentially unused in 19, because the props diff moved into commit (commitUpdate receives old and new props).
  • dependencies: the contexts this fiber read (firstContext list) 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 (has useEffect), Callback, ...5
  • subtreeFlags: the OR of all descendants' flags, bubbled up in completeWork. The commit phase skips any subtree whose subtreeFlags lacks the bits it cares about. This replaced the old "effect list" (nextEffect chain) 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 (a setState on it).
  • childLanes: the OR of lanes pending anywhere below it. Set by markUpdateLaneFromFiberToRoot when 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 === wip and wip.alternate === current.

WorkTag list

7

#TagIsstateNode
0FunctionComponentfunction App()null
1ClassComponentclass X extends Componentinstance
3HostRoottop of treeFiberRoot
4HostPortalcreatePortalportal container info
5HostComponent'div', 'span'...DOM element
6HostTextstring/number childText node
7Fragment<>...</>, arrays
8Mode<StrictMode> etc.
9 / 10ContextConsumer / ContextProvider<Ctx.Consumer> / <Ctx>
11ForwardRefforwardRef(...)
12Profiler
13SuspenseComponent<Suspense>
14 / 15MemoComponent / SimpleMemoComponentmemo(...); "simple" = function, no custom compare
16LazyComponentlazy(...) before resolution
17 / 28IncompleteClassComponent / IncompleteFunctionComponentthrew during mount
18DehydratedFragmenthydration
19SuspenseListComponent
22OffscreenComponenthidden subtree (<Activity>, Suspense content)
23LegacyHiddenComponent
24CacheComponent
25TracingMarkerComponent
26HostHoistable<link>, <meta>, <title> hoisted to <head>resource
27HostSingleton<html>, <head>, <body>existing element
29Throwplaceholder for a thrown child
30ViewTransitionComponent<ViewTransition>
31ActivityComponent<Activity>

(2 and 20 are unused; 21 is ScopeComponent.)

Walking the tree without recursion

The work loop is a depth-first walk done by hand:

JavaScript
// 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. childLanes lets 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 return pointers (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.current points to its HostRoot.
  • workInProgress (WIP): the version being rendered. It is built from current plus 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

JavaScript
// 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 same shared.pending, which is why updates aren't lost when a render is thrown away.
  • StaticMask flags (like LayoutStatic/PassiveStatic, "this fiber has effects at all") survive resets, so unmount knows what cleanup to run even if this render didn't schedule effects.14
  • stateNode is shared: both versions point to the same DOM node or class instance. There's only one DOM.

After 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, a setState deep 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 useMemoCache array on fiber.updateQueue.memoCache (see React Compiler). That is one extra array per compiled component, and it is preserved across the current ↔ WIP clone because updateQueue is shared.
  • Because compiled code returns the same element objects when inputs haven't changed, the child fiber's pendingProps === memoizedProps holds, 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 (null for function components).
  • child / sibling / return: linked children plus a parent pointer, so the loop can pause, abort and resume with no call stack.
  • pendingProps vs memoizedProps: reference-equal props, no pending lanes and unchanged context let beginWork bail out.
  • memoizedState and updateQueue: tag-dependent; the hook list for function components, {element, isDehydrated, cache} for the HostRoot.
  • flags and subtreeFlags: side effects for commit, bubbled up so commit skips clean subtrees.
  • lanes and childLanes: 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 with current until the render overwrites it.
  • Two objects per instance: which fiber is current flips each commit, so never assume you hold the current one.

← Prev · Index · Next →

Footnotes

  1. FiberNode constructor — packages/react-reconciler/src/ReactFiber.js#L134-L207 (opens in a new tab) ↩

  2. Fiber type — packages/react-reconciler/src/ReactInternalTypes.js#L89-L210 (opens in a new tab) ↩

  3. FunctionComponentUpdateQueue.memoCache — packages/react-reconciler/src/ReactFiberHooks.js#L249-L254 (opens in a new tab) ↩

  4. TypeOfMode bits — packages/react-reconciler/src/ReactTypeOfMode.js#L10-L21 (opens in a new tab) ↩

  5. fiber flags (Placement, Update, ChildDeletion, Callback, Ref, Passive) — packages/react-reconciler/src/ReactFiberFlags.js#L18-L36 (opens in a new tab) ↩

  6. markUpdateLaneFromFiberToRoot sets childLanes — packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L189-L208 (opens in a new tab) ↩

  7. WorkTags — packages/react-reconciler/src/ReactWorkTags.js#L44-L73 (opens in a new tab) ↩

  8. shouldConstruct / isSimpleFunctionComponent — packages/react-reconciler/src/ReactFiber.js#L303-L314 (opens in a new tab) ↩

  9. updateMemoComponent upgrades to SimpleMemoComponent — packages/react-reconciler/src/ReactFiberBeginWork.js#L486-L497 (opens in a new tab) ↩

  10. performUnitOfWork — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3081 (opens in a new tab) ↩

  11. root.current = finishedWork — packages/react-reconciler/src/ReactFiberWorkLoop.js#L4047-L4051 (opens in a new tab) ↩

  12. createWorkInProgress — packages/react-reconciler/src/ReactFiber.js#L322-L437 (opens in a new tab) ↩

  13. prepareFreshStack → createWorkInProgress(root.current, null) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L2254-L2258 (opens in a new tab) ↩

  14. LayoutStatic and StaticMask — packages/react-reconciler/src/ReactFiberFlags.js#L73 (opens in a new tab), #L141 (opens in a new tab) ↩