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.
reconcileChildren: the entry point
"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.
reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes): unwrap a keyless top-level Fragment to its children; then by type ofnewChild: object with$$typeofelement →reconcileSingleElement; portal →reconcileSinglePortal; lazy → resolve and recurse; array →reconcileChildrenArray; iterable →reconcileChildrenIterator; string or number →reconcileSingleTextNode; anything else (null, undefined, boolean) →deleteRemainingChildren.- 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) andreconcileChildFibers(updates). Same code, ashouldTrackSideEffectsflag. - Output:
wip.childset to the first new child fiber, siblings linked, each withreturnset; new fibers flagged Placement; removed ones inreturnFiber.deletionswith ChildDeletion on the parent.
useFiber(fiber, pendingProps)=createWorkInProgress(fiber, pendingProps)withindexreset andsiblingcleared: the same fiber object (or its alternate) continues, keepingmemoizedState(hooks),stateNode(the DOM node), andref. Its new props will be compared in its own beginWork for a bailout.- Delete = append to the parent's deletions; the subtree's fibers are not visited now; commit unmounts them (effects cleanup, DOM removal).
- Create =
createFiberFromElement: a new FiberNode with the element's type and props, state empty, no DOM yet (completeWork creates it).
Arrays and keys
// 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.- Pass 1: walk old fibers and new elements together while
updateSlotsucceeds (same key, with null keys matching each other; same type, else the slot returns a new fiber and the old is deleted). TracklastPlacedIndex. For a list where nothing moved, this pass does everything in O(n) with no allocation beyond the fibers. - Early exits: new list exhausted → delete the remaining old fibers. Old list exhausted → create the remaining new fibers (appends; each Placement).
- 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.
placeChildmarks a reused fiber as moved when its old index is belowlastPlacedIndex. Leftover map entries are deleted. - 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).
- 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.
- Append: pass 1 over the old n, then create k. O(n + k).
- 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.
- 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).
- Remove from the middle: pass 1 to the removal point; pass 2 for the rest (all found, all in order: no moves); one deletion.
- 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.
Bailouts, and the memo primitives as modifications to them
// 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.- 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.memoreplaces this reference check with a shallow comparison; the Compiler keeps element references stable so the plain check passes. - Own lanes:
fiber.lanes & renderLanes. Set bysetStateon this fiber (orforceUpdate, or a Suspense retry). Cannot be bailed out; the component must run to process its queue. - Context: a consumer fiber is marked with lanes by
propagateContextChangewhen its provider's value reference changed. Same as own lanes from the bailout's point of view: it must run.memocannot prevent it; only a stable provider value can.
childLanes & renderLanes === 0: nothing below has work; return null; the subtree is not even cloned. The cheapest outcome.- 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.
- A function component whose props reference changed must run (condition 1 failed). After
renderWithHooks, if no hook state actually changed (didReceiveUpdatestill false: the update queue produced the same state) and no context changed, React callsbailoutHooksandbailoutOnAlreadyFinishedWorkanyway: the function's cost is paid, but its children are not reconciled and its subtree is skipped. This is why asetStateto the same value (byObject.is) is nearly free even though the component may be called once ("eager state" checks even avoid the call in the simple case).
Context propagation
- 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. propagateContextChange: a depth-first walk of the provider's entire current subtree. Each fiber has adependencieslist (the contexts it read viauseContextorcontextTypein 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).- 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.
useContextduring 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 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.
- 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. - 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. - The selector pattern:
useSyncExternalStorewith 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).
Reading reconciliation: tools
| Question | Tool | What 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 out | A 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 flashing | Rows mounting on every change have unstable keys |
| Which context caused this? | Profiler "why" → "Context changed"; the Components tab shows consumed contexts per fiber | The 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 time | The 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 components | Whether the component is compilable and what it memoises |
| What is the reconciliation itself costing? | Chrome Performance: time in reconcileChildren / reconcileChildrenArray frames versus component frames | Almost always the components, not the diff; a wide reconcile bar means a huge unkeyed or churning list |