Part 2 · 5 chapters · ~40 min

Reconciliation

reconcileChildren dispatches on what you returned, reuses fibers by key and type, builds the keyed diff in two passes, and the bailout check before any of it decides how much of the tree is visited at all. This part is the single-child decision tree, the array passes on a real reorder, the three bailout conditions and the memo primitives as changes to them, context propagation as an O(subtree) walk, and the same tree costed under four kinds of update.

11

reconcileChildren: the entry point

the question

"beginWork called reconcileChildren with the new elements. What does it do with one element, with an array, with null?"

It dispatches on the shape of what the component returned. One element: find the matching child fiber by key and type and reuse it, or delete and create. A string: a text fiber. Null or false: delete everything. An array or iterable: the two-pass keyed algorithm. The result is the WIP child fibers with Placement flags on new ones and the parent's deletions list filled, and nothing touched in the DOM. The algorithms course (part 7) has the algorithm; this part is the React code paths and what they cost in practice.

the dispatch (ReactChildFiber)
  1. reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes): unwrap a keyless top-level Fragment to its children; then by type of newChild: object with $$typeof element → reconcileSingleElement; portal → reconcileSinglePortal; lazy → resolve and recurse; array → reconcileChildrenArray; iterable → reconcileChildrenIterator; string or number → reconcileSingleTextNode; anything else (null, undefined, boolean) → deleteRemainingChildren.
  2. Two reconcilers: mountChildFibers (no current children: skip deletions and Placement tracking, since everything is new and the whole subtree gets one Placement at its root) and reconcileChildFibers (updates). Same code, a shouldTrackSideEffects flag.
  3. Output: wip.child set to the first new child fiber, siblings linked, each with return set; new fibers flagged Placement; removed ones in returnFiber.deletions with ChildDeletion on the parent.
what "reuse" means
  1. useFiber(fiber, pendingProps) = createWorkInProgress(fiber, pendingProps) with index reset and sibling cleared: the same fiber object (or its alternate) continues, keeping memoizedState (hooks), stateNode (the DOM node), and ref. Its new props will be compared in its own beginWork for a bailout.
  2. Delete = append to the parent's deletions; the subtree's fibers are not visited now; commit unmounts them (effects cleanup, DOM removal).
  3. Create = createFiberFromElement: a new FiberNode with the element's type and props, state empty, no DOM yet (completeWork creates it).
RECONCILING ONE CHILD
the decision tree for a single element
swipe the figure sideways, or tap expand for full screen
1/6
same key, same type
The new child is an element with key null (most single children). Walk the current child fibers (usually one): if a fiber has the same key (null === null) and the same type (elementType match, with a Fragment special case), reuse it: useFiber(child, props) creates the WIP from it, deletes any remaining siblings, and returns it. No Placement flag: it stays where it is.
12

Arrays and keys

code
// keys: what they are for, and the four mistakes
items.map(item => <Row key={item.id} {...item} />)           // identity from data: the diff matches by it; state and DOM follow the item
items.map((item, i) => <Row key={i} {...item} />)            // mistake 1: position pairing. insert at the front → every Row gets the wrong item's state
items.map(item => <Row key={Math.random()} {...item} />)     // mistake 2: new identity every render → remount every render → state loss, DOM churn
items.map(item => <Row key={item.name} {...item} />)         // mistake 3: non-unique (two "Adeniji") → ambiguous map; React warns and misbehaves
<Row key={item.id} /> …later… <Row key={`row-${item.id}`} /> // mistake 4: the key format changed → every row remounts once (harmless once; a bug if it alternates)

// keys are scoped to their parent: the same key under different parents is fine. a Fragment with a key scopes the keys of its children.
// a key on a non-list child is the reset idiom: <Editor key={docId} /> remounts when docId changes, on purpose.
// index keys are correct only when the list never reorders, inserts or removes except at the end, and rows have no state. that is rarer than it sounds.
the array algorithm, as React runs it
  1. Pass 1: walk old fibers and new elements together while updateSlot succeeds (same key, with null keys matching each other; same type, else the slot returns a new fiber and the old is deleted). Track lastPlacedIndex. For a list where nothing moved, this pass does everything in O(n) with no allocation beyond the fibers.
  2. Early exits: new list exhausted → delete the remaining old fibers. Old list exhausted → create the remaining new fibers (appends; each Placement).
  3. Pass 2: on a key mismatch, build a Map of the remaining old fibers by key (index for unkeyed), then for each remaining new element reuse from the map or create. placeChild marks a reused fiber as moved when its old index is below lastPlacedIndex. Leftover map entries are deleted.
  4. Nested arrays and Fragments: a keyed Fragment is one slot whose children are reconciled recursively under it; a nested array in children is treated as a Fragment slot keyed by position (with a warning if its items lack keys).
costs
  1. Unchanged list of n: pass 1 over n; each child then bails out in its own beginWork if props are stable. O(n) slot checks.
  2. Append: pass 1 over the old n, then create k. O(n + k).
  3. Prepend one: pass 1 breaks at 0; a Map of n; n reuses; the heuristic ends up marking none as moved except as needed (the new element is placed first; the old ones have indices ≥ 0 ≥ lastPlacedIndex). One DOM insert. O(n) with a Map allocation.
  4. Reverse: pass 2 over everything; the heuristic marks n−1 moves (every element but one falls behind the last placed). Commit does n−1 insertBefore calls. The LIS variant would do the same here (a reversal has an LIS of 1).
  5. Remove from the middle: pass 1 to the removal point; pass 2 for the rest (all found, all in order: no moves); one deletion.
  6. Unkeyed with a change in the middle: pass 1 matches every position (index keys); each slot with a different type is delete + create; same type gets the wrong item's state. The DOM is updated prop by prop from the wrong starting point: correct output, wasted work, and state attached to the wrong rows.
RECONCILING AN ARRAY
the two passes, on a list that changed
swipe the figure sideways, or tap expand for full screen
1/6
setup
Setup: current children as a linked list of fibers A → B → C → D with keys and indices 0..3; new elements [A, C, B, E]. Variables: oldFiber = A, newIdx = 0, lastPlacedIndex = 0.
13

Bailouts, and the memo primitives as modifications to them

code
// bailoutOnAlreadyFinishedWork, and the three ways to reach it
function beginWork(current, wip, renderLanes) {
  if (current !== null) {
    const oldProps = current.memoizedProps, newProps = wip.pendingProps
    if (oldProps !== newProps || hasLegacyContextChanged()) didReceiveUpdate = true   // (1) props changed by REFERENCE
    else if (!checkScheduledUpdateOrContext(current, renderLanes)) {                    // (2) no own lanes, no context change
      didReceiveUpdate = false
      return attemptEarlyBailoutIfNoScheduledUpdate(current, wip, renderLanes)         // → bailoutOnAlreadyFinishedWork
    }
  }
  /* … render … */
}
function bailoutOnAlreadyFinishedWork(current, wip, renderLanes) {
  if ((wip.childLanes & renderLanes) === 0) return null         // nothing below either: skip the whole subtree (no cloning)
  cloneChildFibers(current, wip)                                  // a descendant has work: clone children (pointers only) and continue down
  return wip.child
}
// (3) the late bailout: a function component whose props reference changed but whose render produced identical hooks state and
// whose didReceiveUpdate stayed false (no state change, no context) can still bail after running: bailoutHooks + bailoutOnAlreadyFinishedWork.
// so: a component CAN run and then bail out of reconciling its children; the function cost is paid, the subtree cost is not.

// React.memo changes (1): instead of oldProps !== newProps, it runs compare(oldProps, newProps) (shallowEqual by default) and bails if equal.
// useMemo / useCallback make (1) pass by keeping the same reference across renders when deps are Object.is-equal.
// the React Compiler emits the equivalent of both from a dependency analysis of each component.
the three conditions, and what sets each
  1. Props reference: oldProps !== newProps. The props object is created by the parent's render when it creates the element. A parent that did not render passes the same object; a parent that did passes a new one. React.memo replaces this reference check with a shallow comparison; the Compiler keeps element references stable so the plain check passes.
  2. Own lanes: fiber.lanes & renderLanes. Set by setState on this fiber (or forceUpdate, or a Suspense retry). Cannot be bailed out; the component must run to process its queue.
  3. Context: a consumer fiber is marked with lanes by propagateContextChange when its provider's value reference changed. Same as own lanes from the bailout's point of view: it must run. memo cannot prevent it; only a stable provider value can.
after a bailout
  1. childLanes & renderLanes === 0: nothing below has work; return null; the subtree is not even cloned. The cheapest outcome.
  2. Otherwise: cloneChildFibers (one WIP fiber per direct child, pointers rewired) and descend; the children run the same check. A deep leaf update clones one fiber per ancestor level.
the late bailout
  1. A function component whose props reference changed must run (condition 1 failed). After renderWithHooks, if no hook state actually changed (didReceiveUpdate still false: the update queue produced the same state) and no context changed, React calls bailoutHooks and bailoutOnAlreadyFinishedWork anyway: the function's cost is paid, but its children are not reconciled and its subtree is skipped. This is why a setState to the same value (by Object.is) is nearly free even though the component may be called once ("eager state" checks even avoid the call in the simple case).
the sizes
A bailout costs a few pointer compares and, if descending, one small allocation per child. A render costs the component function plus reconciliation of its output. At 2,000 fibers, "everything renders" is ~15 ms on a laptop and the bailed-out version is ~0.2 ms; the figure above has the four cases.
WHAT A RENDER COSTS, BY BAILOUT OUTCOME
the same tree, four kinds of update
swipe the figure sideways, or tap expand for full screen
1/6
the tree
Baseline: the tree. 2,000 fibers, depth 12. The input is a leaf; its parent chain is 12 fibers. 200 fibers read a theme context. Everything else is static.
14

Context propagation

how a context change reaches consumers
  1. Provider beginWork: push the new value on the context stack; compare with the old by Object.is. Unchanged → the provider's subtree can bail out normally. Changed → propagateContextChange.
  2. propagateContextChange: a depth-first walk of the provider's entire current subtree. Each fiber has a dependencies list (the contexts it read via useContext or contextType in its last render). For each fiber whose list includes this context: mark its lanes with the render lanes, and mark childLanes up to the provider. The walk is O(subtree) regardless of consumer count; it stops descending only at nested providers of the same context (they shadow it).
  3. Render: consumers now have own lanes and cannot bail out, whatever their props or memo. Their ancestors along the path are visited (childLanes) and bail out or not on their own terms.
  4. useContext during render: reads the current value from the stack (the nearest provider above); records the dependency on the fiber. Reading a context does not subscribe the fiber in any other sense; the propagation walk is the subscription.
the costs and the designs
  1. The walk is O(subtree) per changed provider per render. A provider at the root with a value that changes often (a form's every keystroke in a context) walks the whole app each time, even if two fibers consume it.
  2. Every consumer renders on every change, with no shallow comparison of the parts it uses. A { user, theme, settings } context re-renders theme consumers when user changes.
  3. Designs: split contexts by change frequency; memoise provider values (useMemo) so reference stability matches content stability; keep frequently changing state out of context (a store with selectors: part 7); use context for dependency injection (a client, a theme, a router) rather than for state that ticks.
  4. The selector pattern: useSyncExternalStore with a store and a selector, or a library (Zustand, Jotai, use-context-selector) that subscribes fibers to slices and re-renders only those whose slice changed. React's own context has no selector by design (a proposal exists).
15

Reading reconciliation: tools

QuestionToolWhat to read
Which components rendered in this commit, and why?React DevTools Profiler → commit → "Why did this render?" (enable "Record why each component rendered")Props changed (with names), hooks changed (with indices), context changed, parent rendered
How much of the tree rendered?Profiler flame chart; grey bars bailed outA wide lit region under one state change is missing bailouts
Did memo work?Profiler "why" → "Props changed: (onClick, style)"The props that defeated the shallow compare; usually inline functions and objects
Is a list reordering or remounting?Profiler: mount versus update per row; Rendering → Paint flashingRows mounting on every change have unstable keys
Which context caused this?Profiler "why" → "Context changed"; the Components tab shows consumed contexts per fiberThe provider whose value reference changed
Is the component defined inside another?Profiler: a component that mounts every commit; Components tab shows a new instance each timeThe inner declaration gives a new type every render
Would the compiler fix it?React Compiler playground; the eslint-plugin-react-compiler rule for bailout reasons; the "Memo ✨" badge in DevTools for compiled componentsWhether the component is compilable and what it memoises
What is the reconciliation itself costing?Chrome Performance: time in reconcileChildren / reconcileChildrenArray frames versus component framesAlmost always the components, not the diff; a wide reconcile bar means a huge unkeyed or churning list
the pointer
Part 3 is the scheduler and lanes: how an event becomes a lane, how a lane becomes a render, what interrupts what, and what starvation protection looks like. Reconciliation decides what changed; the scheduler decides when it is allowed to find out.