Fiber
A fiber is a plain object with thirty fields, two of them exist per position, and the reconciler is a loop over one pointer. This part is the node field by field, the two trees and the swap, beginWork by tag, completeWork and the detached host subtrees it builds, the commit pass by pass with the order that explains effect timing, and where the time goes.
The fiber node
"What is a fiber, concretely? Is it the component instance?"
A fiber is a plain JavaScript object, one per rendered element position, with about thirty fields. It is the unit of work for the reconciler: the thing that holds a component's state between renders, remembers the last props, points to the DOM node for host elements, carries the scheduling bits and the effect flags, and links to its parent, first child and next sibling. For function components it is as close to an "instance" as there is; the function itself has no instance.
- Identity:
tag(a small integer for the kind: function, class, host, text, fragment, provider, memo, suspense, lazy, offscreen…),type(the function, class or tag name),key,elementType. - Tree:
return,child,sibling,index. - Input and output:
pendingProps(the new element's props),memoizedProps(last committed),memoizedState(the hook list for function components; the state object for classes),updateQueue(pending class updates; the effect list for function components),dependencies(contexts read). - Scheduling:
lanes,childLanes(part 3). - Effects:
flags,subtreeFlags,deletions(set in render, consumed in commit). - Host and buffering:
stateNode(DOM node, class instance, or the root),ref,alternate,mode.
- State is per position:
memoizedStatelives on the fiber; the same component function at two positions has two fibers and two states; the same position with a changed key gets a new fiber and fresh state. - The previous props are remembered for the bailout comparison (part 2) and for computing host prop diffs in completeWork.
- Flags are how render talks to commit: render never touches the DOM; it marks fibers; commit reads the marks.
subtreeFlagslets commit skip clean subtrees without visiting them. - The DOM node is reachable only through
stateNodeof a host fiber; a component fiber has none, which is why a ref on a function component needs forwarding (or, in React 19,refas a prop) to reach a host child.
__reactFiber$<random> pointing at its host fiber, and __reactProps$… with its props. Object.keys(node).find(k => k.startsWith('__reactFiber')) and follow .return upward to see the fibers of the components above it.Two trees and the alternate pointer
Rendering must not disturb what the user sees, and it must be abandonable. React achieves both by keeping two fiber trees: current (committed, on screen) and work-in-progress (being built). Each fiber points at its counterpart through alternate. Commit swaps the root's pointer; an abandoned render leaves current untouched. The two sets of fiber objects trade roles on every render.
createWorkInProgress(current, pendingProps): ifcurrent.alternateexists, reuse that object (copy fields from current, set the new pendingProps, clear flags); otherwise allocate a new FiberNode and link alternates both ways. Called lazily as the work loop descends, so untouched subtrees are never cloned.- During render: every fiber the loop visits gets a WIP version. Bailed-out fibers are cloned with pointers rewired to their WIP children (
cloneChildFibers); rendered fibers get newmemoizedStateand freshly reconciled children. Current is read, never written. - At commit: after the mutation pass,
root.current = finishedWork. The swap is one pointer write. Layout effects and refs run after it, so they observe the committed tree. - Abandonment: an interruption with a restart (part 3), a thrown promise with no boundary in reach (part 5), or an error, resets
workInProgressto null. The partially built WIP fibers remain as alternates of their current fibers and are reused on the next attempt.
- Memory is roughly twice the fiber count for the lifetime of the tree. Each fiber object is a few hundred bytes; large trees carry tens of megabytes of fibers.
- Hooks state lives on both: during render, hooks read from
current.memoizedStateand write toworkInProgress.memoizedState(part 4). A render that is thrown away leavescurrent's hook list intact, which is why a discarded render does not lose state. - Refs and instances are shared:
stateNode(DOM node, class instance) is the same object in both fibers; the double buffering is of the tree structure and state, not of host nodes. - DevTools shows current; what you inspect is the committed tree, never the in-progress one.
beginWork: rendering one fiber
// beginWork, by tag (ReactFiberBeginWork.js, in outline)
function beginWork(current, workInProgress, renderLanes) {
if (current !== null) { // update (not mount)
const oldProps = current.memoizedProps, newProps = workInProgress.pendingProps
if (oldProps === newProps && !hasContextChanged() && (workInProgress.lanes & renderLanes) === 0)
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) // clone children if childLanes, else null (skip subtree)
}
switch (workInProgress.tag) {
case FunctionComponent: return updateFunctionComponent(…) // renderWithHooks: set the dispatcher, call Component(props), read the hook list;
// then reconcileChildren(current, wip, nextChildren) → child fibers
case ClassComponent: return updateClassComponent(…) // construct or update the instance; process the update queue; shouldComponentUpdate;
// instance.render(); reconcileChildren
case HostRoot: return updateHostRoot(…) // process root updates (the element passed to root.render); reconcile
case HostComponent: return updateHostComponent(…) // no render; children are the props.children elements; text-only children are
// handled without a HostText fiber; reconcile
case HostText: return null // leaf
case Fragment: return reconcileChildren(…) // children only
case ContextProvider: return updateContextProvider(…) // push the value; if changed (Object.is), propagateContextChange marks consumers' lanes
case MemoComponent: return updateMemoComponent(…) // shallowEqual(prev, next) ? bailout : render the inner
case SuspenseComponent: return updateSuspenseComponent(…) // render the primary children; on a thrown promise, show the fallback (part 5)
case LazyComponent: return mountLazyComponent(…) // resolve the import; throw the promise if pending
case OffscreenComponent: return updateOffscreenComponent(…) // hidden subtrees are deferred to the Offscreen lane
}
}
// the contract: returns the next fiber to work on (the first child) or null (this fiber is complete; go to completeUnitOfWork)- Bailout check first (updates only): same props by reference, no context change, no lanes in this render → clone children or skip (part 2).
- Dispatch on tag. Function components:
renderWithHookssets the current dispatcher (mount or update), calls the component, and leaves the hook list onmemoizedState(part 4); then reconciles the returned children. Class components: construct or update the instance, process its update queue, call lifecycle methods andrender. Host components: no call; children are the element'sprops.children. Providers: push the context value and, if it changed, walk the subtree marking consumers. Memo: shallow-compare and bail or render the inner type. Suspense: render the primary children, or on a thrown promise, the fallback (part 5). - Reconcile children:
reconcileChildFibers(wip, current.child, nextChildren)produces the WIP child fibers with Placement flags on new ones and deletions recorded on the parent (part 2). - Return the first child as the next unit of work, or null to complete this fiber.
- Touch the DOM. Host fibers have no new nodes yet; those come in completeWork.
- Run effects. Effects are recorded on the fiber's
updateQueuewith tags; commit runs them. - Guarantee a single call. The component may be called again if the render is restarted, and twice in Strict Mode.
completeWork: host instances and bubbling
// completeWork, by tag (ReactFiberCompleteWork.js, in outline)
function completeWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case HostComponent: {
if (current !== null && workInProgress.stateNode != null) {
updateHostComponent(current, workInProgress, type, newProps) // diffProperties(oldProps, newProps) → an updatePayload array
// [name, value, name, value, …]; if non-empty: flags |= Update
} else {
const instance = createInstance(type, newProps, …) // document.createElement; set initial props
appendAllChildren(instance, workInProgress) // attach already-created child DOM nodes (bottom-up: children completed first)
workInProgress.stateNode = instance
if (finalizeInitialChildren(instance, type, newProps)) markUpdate() // autoFocus etc.
}
if (workInProgress.ref !== null) markRef(workInProgress)
break
}
case HostText: { /* create or update the text node; flags |= Update if text changed */ break }
case FunctionComponent: case ClassComponent: case Fragment: /* nothing host-level */ break
case SuspenseComponent: { /* decide fallback vs content; schedule retry lanes */ break }
}
bubbleProperties(workInProgress) // subtreeFlags |= each child's flags | subtreeFlags; childLanes likewise
return null
}
// so by the time the root completes: every new host node exists (unattached to the document), every update has a payload,
// and every fiber's subtreeFlags says whether commit must look below it. commit then does the attaching and applying.- Host components on mount: create the DOM element (
document.createElement), set its initial properties, and append the already-created DOM nodes of its host children (completion is bottom-up, so children's nodes exist). The result is a detached subtree per new host fiber, attached to the document only in commit. - Host components on update:
diffProperties(oldProps, newProps)produces a flat update payload ([name, value, …]) of changed props, includingstylesub-properties and event props; a non-empty payload sets the Update flag. The diff is per prop, O(props), with special cases forstyle,children(text),dangerouslySetInnerHTML, and value/checked for controlled inputs. - Host text: create or update the text node; Update if the string changed.
- Refs: mark the Ref flag so commit attaches it.
- Suspense boundaries: decide whether the primary tree completed or the fallback shows; schedule retry lanes.
- Bubble:
subtreeFlags |= child.flags | child.subtreeFlagsand the same for lanes, for every child. This is what lets commit skip clean subtrees and lets the next render know where pending work is.
- Mounting a subtree creates every DOM node during the render phase (completeWork), detached, then attaches the root of the new subtree in one
insertBeforeat commit. The browser sees one insertion of a built tree, which is far cheaper than node-by-node insertion into the live document (one layout invalidation, not n). - Updates apply only the payload: a changed
classNameis one attribute set; unchanged props are not touched. - Large initial mounts are therefore dominated by element creation and property setting in completeWork, then the browser's single layout of the attached subtree. The React cost is linear in nodes; the browser cost (the browser course part 4) is where big mounts hurt.
The commit, pass by pass
Commit is where React touches the world. It is synchronous and cannot be interrupted: once it starts, the browser does not paint until it finishes. It makes three passes over the fibers that have flags (skipping clean subtrees via subtreeFlags), each pass doing one category of work in a fixed order, and schedules a fourth for after paint.
- Before mutation:
getSnapshotBeforeUpdatefor classes (the one place to read the DOM as it was); scheduling of the passive effects flush. No visible change. - Mutation: in tree order: process
deletions(recursively: detach refs, run layout-effect cleanups andcomponentWillUnmount, remove the host node from its host parent); then for each flagged fiber: Placement (insert the host node before the nearest following host sibling, found by walking the fiber tree), Update (apply the payload; set text), Ref (detach the old ref). Thenroot.current = finishedWork. - Layout: child-before-parent: attach refs (
ref.current = stateNodeor call the callback ref), runuseLayoutEffectcallbacks (their cleanups ran in the mutation pass for fibers whose deps changed), runcomponentDidMountandcomponentDidUpdate, handleautoFocus. The DOM is in its final state and can be measured; nothing has been painted. - Paint. React returns; the browser renders the frame.
- Passive:
flushPassiveEffectsin a later task: alluseEffectcleanups (for unmounted fibers and for changed dependencies), child before parent; then alluseEffectcallbacks, child before parent. In Strict Mode, development mounts run effect → cleanup → effect to surface cleanup bugs.
- A child's effect runs before its parent's (both layout and passive): completion order. A parent that needs children to be set up first gets that for free; a child that needs the parent's effect to have run does not.
- Layout effects see the final DOM and block paint; passive effects see the painted DOM and do not. Measuring in
useEffectand setting state causes a visible flash (two paints); doing it inuseLayoutEffectdoes not (one paint, after the correction). - Refs are usable in both effect kinds because attachment precedes layout effects.
- Cleanups always run before the next callbacks for the same effect, and all cleanups of a flush run before any callback, so an effect never observes a sibling's next-render state during its cleanup.
- A deleted subtree's cleanups run in mutation, before its DOM is removed; a layout-effect cleanup may still measure the node.
The work loop and where time goes
- Entry:
performConcurrentWorkOnRoot(orperformSyncWorkOnRootfor sync lanes) is the scheduler callback. It computesrenderLanes(part 3), prepares a fresh stack if the lanes changed, and runsworkLoopConcurrent:while (workInProgress !== null && !shouldYield()) performUnitOfWork(workInProgress). The sync variant has noshouldYield. performUnitOfWork:next = beginWork(current, wip, lanes);wip.memoizedProps = wip.pendingProps; ifnextis null,completeUnitOfWork(wip): complete, then sibling or return, repeatedly; elseworkInProgress = next.- Exit: the root completed →
finishConcurrentRender→commitRoot; or yielded → return a continuation so the scheduler calls back; or thrown → handle Suspense or errors (part 5) and possibly restart.
| Where | Cost driver | Seen as | Lever |
|---|---|---|---|
| beginWork: component call | Your render function's work; hooks; element allocation | Profiler bar width per component | Less work in render; memo for bailouts; move computation to events or effects |
| beginWork: reconciliation | Children count; keyed diff passes; type comparisons | Wide bars on hosts with many children | Keys; stable element types; virtualise long lists |
| completeWork: host instances | Nodes created on mount; prop diffs on update | Mount commits; "Update" counts | Fewer DOM nodes; fewer changing props; avoid inline style objects that always differ |
| Commit: mutation | DOM operations; the browser's invalidation per operation | "commitRoot" block in the Performance panel before paint | Batch placements (keys avoid churn); avoid prepending; avoid layout-affecting props |
| Commit: layout effects | Synchronous measurement and DOM writes; forced layouts | Long commitRoot; "Forced reflow" warnings | Measure once; batch writes; prefer CSS; move non-measuring work to useEffect |
| Passive effects | Effect bodies; setState chains causing more renders | Extra commits after the first; "flushPassiveEffects" task | Derive instead of sync; event handlers instead of effects (part 11) |
| The browser after commit | Style, layout, paint of the mutated DOM (the browser course) | Layout and Paint after commitRoot | Everything in that course: containment, compositor properties, fewer nodes |
reconcileChildren; the next part opens that call.