Notes from the legacy React docs page Reconciliation, react.dev’s Preserving and Resetting State, and the React 19.2 source (
updateElement,updateSlot,useFiber). Part of React Internals. It follows Render and Commit, and the list algorithm itself is in Child Reconciliation Algorithm. My own notes and clarifications are marked with> 💬.
Interview Q&A
What is reconciliation?
The process of comparing the elements a component just returned with what React rendered last time, to decide which fibers (and DOM nodes) to keep, update, create or destroy. The fiber architecture doc defines it as “the algorithm React uses to diff one tree with another to determine which parts need to be changed.” It happens in the render phase. The DOM changes it decides on are applied later, in the commit.
Why doesn't React use an optimal tree-diff algorithm?
The best general algorithms for turning one tree into another are O(n³). The legacy docs note that for 1,000 elements that’s on the order of a billion comparisons. React uses an O(n) heuristic instead, based on two assumptions that hold for real UIs.
What are those two assumptions?
(1) “Two elements of different types will produce different trees”, so a
type change replaces the whole subtree without comparing inside it. (2) “The
developer can hint at which child elements may be stable across different
renders with a key prop.”
How does React decide whether a component keeps its state?
By position in the tree plus type plus key. The same component
type at the same position (with the same key) keeps its fiber, and therefore
its state. Change any of the three and React unmounts the old one and mounts
a fresh one. In our example, <Chat key={to.id} /> resets the draft message
when you switch contacts.
Why should you never define a component inside another component?
Every render creates a new function, so the element’s type is a
different value each time. React sees a different type at the same position
and remounts the subtree, losing its state and DOM, on every render.
The problem: diffing two trees is expensive
A component returns a tree of elements. On the next render it returns another. React has to turn “old tree” into “new tree” with as few DOM operations as possible. Solving that minimum edit distance between two arbitrary trees is O(n³), which the legacy docs call “far too expensive”. A UI with 1,000 elements would take about a billion comparisons per update.
React gives up on finding the minimal set of edits and settles for a good-enough one found in a single top-down pass. Each node is compared only with the node at the same position in the previous tree. React never searches the tree to see whether a subtree moved somewhere else. That makes it O(n), and two rules make the result good in practice.
Rule 1: different type → throw away the subtree
When an element at a position has a different type than last time, React
does not try to match up the children. The legacy docs: “Whenever the root
elements have different types, React will tear down the old tree and build
the new tree from scratch.”
- render phase
App()App re-renders. Last time it returned<div><Counter/></div>; now it returns<span><Counter/></span>.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › renderWithHooks › App() - render phase
reconcileChildFibers(App, currentFirstChild: div, newChild: <span>)React reconciles App’s child: the currentdivfiber against the newspanelement. Only this one position is compared.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › reconcileChildren › reconcileChildFibers - render phase
reconcileSingleElement → child.key === element.keyFirst the key. Both arenull, so this is the same slot.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › reconcileChildren › reconcileChildFibers › reconcileSingleElement - render phase
child.elementType === element.typeThen the type:'div' !== 'span'. A different type means React assumes a completely different subtree. It doesn’t look inside.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › reconcileChildren › reconcileChildFibers › reconcileSingleElement - render phase
deleteRemainingChildren(App, div) // App.flags |= ChildDeletionThe olddivfiber is marked for deletion, and its whole subtree goes with it:Counterand its state (count: 3), even though a<Counter/>is still being rendered.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › reconcileChildren › reconcileChildFibers › reconcileSingleElement › deleteRemainingChildren - render phase
createFiberFromElement(<span>)A brand-new fiber forspan: no alternate, nothing reused. It is flagged Placement (insert).stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › reconcileChildren › reconcileChildFibers › reconcileSingleElement › createFiberFromElement › createFiberFromTypeAndProps - render phase
beginWork(span) → mountChildFibers(span, null, <Counter />)Inside a new subtree there is nothing old to compare against, so every child is mounted from scratch.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(span) › reconcileChildren › reconcileChildFibers - render phase
Counter() → mountState(0)Counterruns as a mount:useStatereturns the initial value0. The oldcount: 3lives on a fiber that is about to be destroyed.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › mountState - commit phase
commitDeletionEffects(div)Commit, mutation phase: the deletedCounterruns its effect cleanups and refs are detached…stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitDeletionEffects - commit phase
removeChild(<div>) · appendChild(<span>)…then the old<div>is removed and the new<span>subtree (built off-screen duringcompleteWork) is inserted in one go. The counter shows 0.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitPlacement › insertOrAppendPlacementNode
// before // after
<div> <span>
<Counter /> <Counter />
</div> </span>
Counter looks identical, but its parent changed from div to span, so
the old Counter is unmounted, meaning its state is destroyed and its
effect cleanups run. A brand-new Counter is mounted. The same happens for
component types: <Article /> → <Comment /> at the same spot remounts.
In the source this is the check in updateElement (for list children) and
reconcileSingleElement (for a single child, inlined into
reconcileChildFibersImpl in the build), in simplified form:
if (current !== null && current.elementType === element.type) {
// same type: reuse the fiber (and its state and DOM node), only props change
const existing = useFiber(current, element.props)
existing.return = returnFiber
return existing
}
// different type (or nothing there before): create a new fiber
const created = createFiberFromElement(element, returnFiber.mode, lanes)
created.return = returnFiber
return created
useFiber calls createWorkInProgress(current, pendingProps), which returns
the alternate of the existing fiber with the new props. That’s why state
survives: the hooks list lives on the fiber (see React Fiber and
Hooks Under the Hood).
Rule 2: same type → keep it and update
Same host element (<div> → <div>): the fiber and its DOM node are
kept. The render phase flags the fiber Update if it got a new props object.
The commit (commitUpdate) then compares old and new props and touches only
the attributes that changed. The legacy docs’ example: going from
<div className="before" title="stuff" /> to
<div className="after" title="stuff" />, “React knows to only modify the
className on the underlying DOM node.” For style, only the changed style
properties are updated.
<button>3</button>
</div>
- render phase
reconcileSingleElement(parent, div, <div className="after">)Same position, so React compares the currentdivfiber with the new<div>element.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(parent) › reconcileChildren › reconcileChildFibers › reconcileSingleElement - render phase
child.key === element.keyKeys match (null).stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(parent) › reconcileChildren › reconcileChildFibers › reconcileSingleElement - render phase
child.elementType === element.typeTypes match:'div' === 'div'.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(parent) › reconcileChildren › reconcileChildFibers › reconcileSingleElement - render phase
useFiber(div, newProps) → createWorkInProgress(div, pendingProps)Same type → keep it. React reuses the fiber (via its alternate) and with it the same DOM node (stateNode). Only the props are new.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(parent) › reconcileChildren › reconcileChildFibers › reconcileSingleElement › useFiber › createWorkInProgress - render phase
beginWork(Counter): same type → reuse, call Counter(props)Going down,Counteris also the same type at the same position, so its hook list, andcount: 3, survive.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() - render phase
completeWork(div): oldProps !== newProps → markUpdate()On the way back up, the host fiber got a new props object, so it is flagged Update. Nothing is written to the DOM yet.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › completeUnitOfWork › completeWork(div) - commit phase
commitUpdate(dom, 'div', oldProps, newProps)Commit compares the old and new props one by one…stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitUpdate - commit phase
dom.className = 'after'…and writes only what changed.titleisn’t touched, the node isn’t recreated, andCounterstill shows 3.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitUpdate
Same component type: the fiber is kept, so hooks and state are preserved. React calls the component with the new props (unless it can bail out, see The Work Loop) and then reconciles its output against the old children, going down level by level.
Rule 3: keys identify children in a list
When a fiber has several children, React has to pair each new child with an old one. Without keys it pairs them by index. The legacy docs’ example shows why that breaks when inserting at the front:
- render phase
reconcileChildrenArray(ul, [Duke, Villanova], [Connecticut, Duke, Villanova])No keys, so children are matched by position: old #0 with new #0, and so on. - render phase
updateSlot(old #0 Duke, new #0 Connecticut)Slot 0: keynull === null, typeli === li→ reuse the Duke fiber and give it the text “Connecticut”. - render phase
updateSlot(old #1 Villanova, new #1 Duke)Slot 1: same story. The Villanova fiber is reused to show “Duke”. - render phase
createChild(ul, <li>Villanova</li>) // PlacementThe old list ran out, so new #2 is a brand-new fiber. - commit phase
commitTextUpdate ×2 · appendChild(<li>)3 DOM operations. Every existing row was rewritten, and any state inside a row (an input, a checkbox) is now attached to the wrong school.
- render phase
updateSlot(old #0 key='duke', new #0 key='conn')Pass 1 walks both lists while keys line up.'conn' !== 'duke', soupdateSlotreturnsnulland the fast path stops. - render phase
mapRemainingChildren(oldFiber)Pass 3: the remaining old fibers go into aMapkeyed bykey. - render phase
updateFromMap(map, 'conn') → not found → createFiberFromElement“conn” isn’t in the map: a new fiber, flagged Placement (insert). - render phase
updateFromMap(map, 'duke') → reuse · placeChild: oldIndex 0 ≥ lastPlacedIndex 0“duke” is found and its fiber reused (removed from the map). Its old index 0 isn’t smaller thanlastPlacedIndex, so it stays. - render phase
updateFromMap(map, 'vill') → reuse · placeChild: oldIndex 1 ≥ 0“vill” is reused and stays too.lastPlacedIndexbecomes 1. The map is now empty, so nothing is deleted. - commit phase
insertBefore(<li>Connecticut</li>, <li>Duke</li>)1 DOM operation. Duke and Villanova are untouched, with their state.
<ul> <ul>
<li>Duke</li> <li>Connecticut</li> ← index 0: "Duke" fiber, text changed
<li>Villanova</li> <li>Duke</li> ← index 1: "Villanova" fiber, text changed
</ul> <li>Villanova</li> ← index 2: new fiber
</ul>
“React will mutate every child instead of realizing it can keep the <li>Duke</li>
and <li>Villanova</li> subtrees intact.” With keys, React matches
children by key instead, so it keeps both existing items and inserts one new
node. The exact matching algorithm (a fast path in order, then a Map of the
remaining keys, then deciding which nodes have to move) is covered in
Child Reconciliation Algorithm.
react.dev adds that keys are local: “Keys are not globally unique. They only specify the position within the parent.”
What this means for your code: state is tied to position
react.dev’s rule: “React keeps track of which state belongs to which component based on their place in the UI tree.” A few consequences follow from that.
- trigger
setIsPlayerA(false)Switch players. - render phase
reconcileSingleElement(div, Counter, <Counter person="Sarah" />)Both branches put aCounteras child #0. React only sees the position, not which line of JSX produced it. - render phase
key null === null ✓ · type Counter === Counter ✓ → useFiberSame key, same type → reuse the fiber, hooks and all. - render phase
Counter({ person: "Sarah" })Counter re-renders with the new props but the old state: Sarah inherits Taylor’s score. - commit phase
commitTextUpdate("Taylor" → "Sarah")Only the name changes on screen. Usually not what you want.
- trigger
setIsPlayerA(false)Switch players. - render phase
reconcileChildrenArray(div, [Counter, null], [false, <Counter />])The children array always has two slots. Afalseleaves a hole, so the counters never share an index. - render phase
slot #0: Counter vs false → deleteChild(Counter)Slot 0 now renders nothing: Taylor’s Counter is deleted, with its state. - render phase
slot #1: nothing vs <Counter /> → createChild · mountState(0)Slot 1 had nothing before, so Sarah gets a fresh Counter starting at 0. - commit phase
removeChild(old) · insertBefore(new)Different positions = different components. Each player keeps a separate counter.
- trigger
setIsPlayerA(false)Switch players. - render phase
reconcileSingleElement(div, Counter, <Counter key="Sarah" />)Same position (#0), same type, but now there is a key to check first. - render phase
child.key === element.key → 'Taylor' !== 'Sarah' ✗Different key → not the same component. The old fiber is deleted… - render phase
createFiberFromElement(<Counter key="Sarah" />) · mountState(0)…and a new one is mounted with fresh state. - commit phase
removeChild(old) · insertBefore(new)Changing thekeyis the standard way to reset a component.
It’s the tree position, not where the JSX is written. These two
branches render Counter at the same position (first child of a div), so
state is preserved when isFancy flips:
if (isFancy) {
return (
<div>
<Counter isFancy={true} />
</div>
)
}
return (
<div>
<Counter isFancy={false} />
</div>
)
react.dev: “It’s the position in the UI tree—not in the JSX markup—that matters to React!”
Different type at the same position resets state:
{isPaused ? <p>See you later!</p> : <Counter />}
Two ways to force a reset when the same component shows different things:
// 1. different positions: two separate slots, only one filled at a time
{isPlayerA && <Counter person="Taylor" />}
{!isPlayerA && <Counter person="Sarah" />}
// 2. different keys at the same position
{isPlayerA ? (
<Counter key="Taylor" person="Taylor" />
) : (
<Counter key="Sarah" person="Sarah" />
)}
Option 1 works because
{cond && <X/>}leaves afalsehole in the children array. The two counters are children #0 and #1, never the same index. That’s also why conditional rendering with&&doesn’t shift the positions of siblings that come after it.
Components defined inside components remount every render:
function MyComponent() {
function MyTextField() { // a NEW function on every render of MyComponent
const [text, setText] = useState('')
return <input value={text} onChange={(e) => setText(e.target.value)} />
}
return <MyTextField />
}
element.type is a new function each time, so current.elementType === element.type is false and Rule 1 applies. The input loses its text and focus
on every keystroke. Define components at module level.
Reconciliation, step by step
App re-renders after setShowBanner(false):
// previous render // this render
<main> <main>
<Banner /> {false}
<Header title="Hi" /> <Header title="Hi!" />
<ul> <ul>
<li key="a">A</li> <li key="b">B</li>
<li key="b">B</li> <li key="a">A</li>
</ul> </ul>
</main> </main>
| Position | Old | New | Decision |
|---|---|---|---|
main |
main |
main |
same type → reuse fiber and DOM node, reconcile children |
main › #0 |
Banner |
false |
nothing to render → delete Banner (unmount, effect cleanups) |
main › #1 |
Header |
Header |
same type → reuse fiber and state, call Header with new props |
main › #2 |
ul |
ul |
same type → reuse, reconcile keyed children |
ul › keys |
a, b |
b, a |
both keys found → reuse both fibers. One of them gets a Placement (move) flag. See Child Reconciliation Algorithm |
The render phase only records these decisions as flags (ChildDeletion,
Placement, Update). The commit phase applies them
(Commit Phase and Effects).
Rules and caveats
- Type identity matters. Don’t create components (or
memo/lazywrappers) during render. Hoist them to module scope. - Keys must be stable, predictable and unique among siblings. The legacy
docs warn that unstable keys “(like those produced by
Math.random()) will cause many component instances and DOM nodes to be unnecessarily recreated, which can cause performance degradation and lost state in child components.” - Index keys are fine only for static lists. If items are inserted, removed or reordered, state and DOM (inputs, focus) stick to the wrong item.
- Use
keyto reset on purpose.<Profile key={userId} />is the idiomatic way to discard all state when the identity changes, instead of syncing it in an effect. - Wrapping changes the position. Adding a
<div>or a<Suspense>around a component changes its parent type, and it remounts.
Sources
- Legacy React docs: Reconciliation
- react.dev: Preserving and Resetting State
- Andrew Clark: React Fiber Architecture
- React 19.2.5 source:
updateElement,updateSlot,useFiber,createWorkInProgress
Primary source: legacy.reactjs.org/docs/reconciliation.html