Part 1 · 6 chapters · ~45 min

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.

5

The fiber node

the question

"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.

the field groups
  1. 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.
  2. Tree: return, child, sibling, index.
  3. 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).
  4. Scheduling: lanes, childLanes (part 3).
  5. Effects: flags, subtreeFlags, deletions (set in render, consumed in commit).
  6. Host and buffering: stateNode (DOM node, class instance, or the root), ref, alternate, mode.
what follows from the fields
  1. State is per position: memoizedState lives 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.
  2. The previous props are remembered for the bailout comparison (part 2) and for computing host prop diffs in completeWork.
  3. Flags are how render talks to commit: render never touches the DOM; it marks fibers; commit reads the marks. subtreeFlags lets commit skip clean subtrees without visiting them.
  4. The DOM node is reachable only through stateNode of a host fiber; a component fiber has none, which is why a ref on a function component needs forwarding (or, in React 19, ref as a prop) to reach a host child.
see one
In a browser console, a DOM node rendered by React has a property named __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.
A FIBER NODE
the fields, grouped by what reads them
swipe the figure sideways, or tap expand for full screen
1/6
identity
Identity: tag (a small integer: FunctionComponent 0, ClassComponent 1, HostRoot 3, HostComponent 5, HostText 6, Fragment 7, ContextProvider 10, SuspenseComponent 13, MemoComponent 14, …), type (the function, class, or tag string), key (from the element), elementType (the original type before memo/lazy unwrapping).
6

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.

the mechanics
  1. createWorkInProgress(current, pendingProps): if current.alternate exists, 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.
  2. 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 new memoizedState and freshly reconciled children. Current is read, never written.
  3. 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.
  4. Abandonment: an interruption with a restart (part 3), a thrown promise with no boundary in reach (part 5), or an error, resets workInProgress to null. The partially built WIP fibers remain as alternates of their current fibers and are reused on the next attempt.
consequences
  1. 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.
  2. Hooks state lives on both: during render, hooks read from current.memoizedState and write to workInProgress.memoizedState (part 4). A render that is thrown away leaves current's hook list intact, which is why a discarded render does not lose state.
  3. 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.
  4. DevTools shows current; what you inspect is the committed tree, never the in-progress one.
THE TWO TREES
current, work-in-progress, and the swap at commit
swipe the figure sideways, or tap expand for full screen
1/6
idle
Idle: root.current points at tree A. Every fiber in A has alternate = null (or a stale B from the previous render). The user sees A.
7

beginWork: rendering one fiber

code
// 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)
what beginWork does
  1. Bailout check first (updates only): same props by reference, no context change, no lanes in this render → clone children or skip (part 2).
  2. Dispatch on tag. Function components: renderWithHooks sets the current dispatcher (mount or update), calls the component, and leaves the hook list on memoizedState (part 4); then reconciles the returned children. Class components: construct or update the instance, process its update queue, call lifecycle methods and render. Host components: no call; children are the element's props.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).
  3. 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).
  4. Return the first child as the next unit of work, or null to complete this fiber.
what it does not do
  1. Touch the DOM. Host fibers have no new nodes yet; those come in completeWork.
  2. Run effects. Effects are recorded on the fiber's updateQueue with tags; commit runs them.
  3. Guarantee a single call. The component may be called again if the render is restarted, and twice in Strict Mode.
in a profile
React DevTools Profiler shows beginWork time per fiber as the flame chart bar width. A wide bar on a component is its function's execution time; a wide bar on a host element is its children's reconciliation.
8

completeWork: host instances and bubbling

code
// 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.
what completeWork does
  1. 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.
  2. Host components on update: diffProperties(oldProps, newProps) produces a flat update payload ([name, value, …]) of changed props, including style sub-properties and event props; a non-empty payload sets the Update flag. The diff is per prop, O(props), with special cases for style, children (text), dangerouslySetInnerHTML, and value/checked for controlled inputs.
  3. Host text: create or update the text node; Update if the string changed.
  4. Refs: mark the Ref flag so commit attaches it.
  5. Suspense boundaries: decide whether the primary tree completed or the fallback shows; schedule retry lanes.
  6. Bubble: subtreeFlags |= child.flags | child.subtreeFlags and 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.
the consequence for mount cost
  1. Mounting a subtree creates every DOM node during the render phase (completeWork), detached, then attaches the root of the new subtree in one insertBefore at 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).
  2. Updates apply only the payload: a changed className is one attribute set; unchanged props are not touched.
  3. 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.
9

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.

the passes
  1. Before mutation: getSnapshotBeforeUpdate for classes (the one place to read the DOM as it was); scheduling of the passive effects flush. No visible change.
  2. Mutation: in tree order: process deletions (recursively: detach refs, run layout-effect cleanups and componentWillUnmount, 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). Then root.current = finishedWork.
  3. Layout: child-before-parent: attach refs (ref.current = stateNode or call the callback ref), run useLayoutEffect callbacks (their cleanups ran in the mutation pass for fibers whose deps changed), run componentDidMount and componentDidUpdate, handle autoFocus. The DOM is in its final state and can be measured; nothing has been painted.
  4. Paint. React returns; the browser renders the frame.
  5. Passive: flushPassiveEffects in a later task: all useEffect cleanups (for unmounted fibers and for changed dependencies), child before parent; then all useEffect callbacks, child before parent. In Strict Mode, development mounts run effect → cleanup → effect to surface cleanup bugs.
what the order explains
  1. 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.
  2. Layout effects see the final DOM and block paint; passive effects see the painted DOM and do not. Measuring in useEffect and setting state causes a visible flash (two paints); doing it in useLayoutEffect does not (one paint, after the correction).
  3. Refs are usable in both effect kinds because attachment precedes layout effects.
  4. 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.
  5. A deleted subtree's cleanups run in mutation, before its DOM is removed; a layout-effect cleanup may still measure the node.
THE COMMIT, PASS BY PASS
before mutation, mutation, layout, then passive
swipe the figure sideways, or tap expand for full screen
1/6
input: flags
Input: the finished WIP tree with flags on fibers and subtreeFlags summarising below. Commit walks only into subtrees whose subtreeFlags are set; a clean subtree is skipped entirely.
10

The work loop and where time goes

the loop
  1. Entry: performConcurrentWorkOnRoot (or performSyncWorkOnRoot for sync lanes) is the scheduler callback. It computes renderLanes (part 3), prepares a fresh stack if the lanes changed, and runs workLoopConcurrent: while (workInProgress !== null && !shouldYield()) performUnitOfWork(workInProgress). The sync variant has no shouldYield.
  2. performUnitOfWork: next = beginWork(current, wip, lanes); wip.memoizedProps = wip.pendingProps; if next is null, completeUnitOfWork(wip): complete, then sibling or return, repeatedly; else workInProgress = next.
  3. 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.
WhereCost driverSeen asLever
beginWork: component callYour render function's work; hooks; element allocationProfiler bar width per componentLess work in render; memo for bailouts; move computation to events or effects
beginWork: reconciliationChildren count; keyed diff passes; type comparisonsWide bars on hosts with many childrenKeys; stable element types; virtualise long lists
completeWork: host instancesNodes created on mount; prop diffs on updateMount commits; "Update" countsFewer DOM nodes; fewer changing props; avoid inline style objects that always differ
Commit: mutationDOM operations; the browser's invalidation per operation"commitRoot" block in the Performance panel before paintBatch placements (keys avoid churn); avoid prepending; avoid layout-affecting props
Commit: layout effectsSynchronous measurement and DOM writes; forced layoutsLong commitRoot; "Forced reflow" warningsMeasure once; batch writes; prefer CSS; move non-measuring work to useEffect
Passive effectsEffect bodies; setState chains causing more rendersExtra commits after the first; "flushPassiveEffects" taskDerive instead of sync; event handlers instead of effects (part 11)
The browser after commitStyle, layout, paint of the mutated DOM (the browser course)Layout and Paint after commitRootEverything in that course: containment, compositor properties, fewer nodes
the pointer
Part 2 is reconciliation in detail: the single-child and array algorithms, keys, bailouts, and the memo primitives as modifications to the checks. beginWork called reconcileChildren; the next part opens that call.