Must knowReact Internals · 03

Reconciliation

The O(n³) → O(n) heuristics (type and key), and why state is tied to position in the tree.

This section:trigger→schedule→render→commit→effects
On this page · 8 sections
  1. Interview Q&A
  2. The problem: diffing two trees is expensive
  3. Rule 1: different type → throw away the subtree
  4. Rule 2: same type → keep it and update
  5. Rule 3: keys identify children in a list
  6. What this means for your code: state is tied to position
  7. Reconciliation, step by step
  8. Rules and caveats

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

current fibers (on screen)
App
divflag
Countercount: 3
new elements (App returned)
<span>
<Counter />
work-in-progress
spanflag
Countercount: 0
checks
key: ?type: ?
DOM
<div><button>3</button></div>
<span><button>0</button></span>
  1. render phaseApp()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()
  2. render phasereconcileChildFibers(App, currentFirstChild: div, newChild: <span>)React reconciles App’s child: the current div fiber against the new span element. Only this one position is compared.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › reconcileChildren › reconcileChildFibers
  3. render phasereconcileSingleElement → child.key === element.keyFirst the key. Both are null, so this is the same slot.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › reconcileChildren › reconcileChildFibers › reconcileSingleElement
  4. render phasechild.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
  5. render phasedeleteRemainingChildren(App, div) // App.flags |= ChildDeletionThe old div fiber is marked for deletion, and its whole subtree goes with it: Counter and 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
  6. render phasecreateFiberFromElement(<span>)A brand-new fiber for span: no alternate, nothing reused. It is flagged Placement (insert).stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(App) › updateFunctionComponent › reconcileChildren › reconcileChildFibers › reconcileSingleElement › createFiberFromElement › createFiberFromTypeAndProps
  7. render phasebeginWork(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
  8. render phaseCounter() → mountState(0)Counter runs as a mount: useState returns the initial value 0. The old count: 3 lives on a fiber that is about to be destroyed.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › mountState
  9. commit phasecommitDeletionEffects(div)Commit, mutation phase: the deleted Counter runs its effect cleanups and refs are detached…stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitDeletionEffects
  10. commit phaseremoveChild(<div>) · appendChild(<span>)…then the old <div> is removed and the new <span> subtree (built off-screen during completeWork) is inserted in one go. The counter shows 0.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitPlacement › insertOrAppendPlacementNode
current stepbeing comparedcomponent runningcreateddeletedokwrong

0 / 0
Rule 1, animated: one failed type check throws away the entire subtree, including state that “looks” identical.
// 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.

current fiber
div#node-1flag
Countercount: 3
new element
<div className="after">
<Counter />
checks
key: ?type: ?
props diff (commitUpdate)
className: 'before' → 'after'title: 'stuff' → 'stuff'
DOM
<div class="before" title="stuff">
  <button>3</button>
</div>
  1. render phasereconcileSingleElement(parent, div, <div className="after">)Same position, so React compares the current div fiber with the new <div> element.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(parent) › reconcileChildren › reconcileChildFibers › reconcileSingleElement
  2. render phasechild.key === element.keyKeys match (null).stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(parent) › reconcileChildren › reconcileChildFibers › reconcileSingleElement
  3. render phasechild.elementType === element.typeTypes match: 'div' === 'div'.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(parent) › reconcileChildren › reconcileChildFibers › reconcileSingleElement
  4. render phaseuseFiber(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
  5. render phasebeginWork(Counter): same type → reuse, call Counter(props)Going down, Counter is also the same type at the same position, so its hook list, and count: 3, survive.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter()
  6. render phasecompleteWork(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)
  7. commit phasecommitUpdate(dom, 'div', oldProps, newProps)Commit compares the old and new props one by one…stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitUpdate
  8. commit phasedom.className = 'after'…and writes only what changed. title isn’t touched, the node isn’t recreated, and Counter still shows 3.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › commitRoot › flushMutationEffects › commitMutationEffectsOnFiber › commitUpdate
current stepbeing comparedreused / keptupdated / movedok

0 / 0
Rule 2, animated: same type keeps the fiber, the DOM node and the state; only changed attributes are written.

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:

No keys (by index)
old fibers (by index)
0 · Duke
1 · Villanova
new elements
0 · Connecticut
1 · Duke
2 · Villanova
DOM
<ul>
  <li>Duke</li>
  <li>Villanova</li>
  <li>Villanova</li>
</ul>
  1. render phasereconcileChildrenArray(ul, [Duke, Villanova], [Connecticut, Duke, Villanova])No keys, so children are matched by position: old #0 with new #0, and so on.
  2. render phaseupdateSlot(old #0 Duke, new #0 Connecticut)Slot 0: key null === null, type li === li → reuse the Duke fiber and give it the text “Connecticut”.
  3. render phaseupdateSlot(old #1 Villanova, new #1 Duke)Slot 1: same story. The Villanova fiber is reused to show “Duke”.
  4. render phasecreateChild(ul, <li>Villanova</li>) // PlacementThe old list ran out, so new #2 is a brand-new fiber.
  5. commit phasecommitTextUpdate ×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.
current stepbeing comparedcreatedreused / keptupdated / moveddeleted

0 / 0
Inserting at the front of a list, with and without keys.
<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.

Ternary, no key
state
isPlayerA = true
children of div
#0
CounterTaylor · count 5
screen
Score for Taylor: 5
  1. triggersetIsPlayerA(false)Switch players.
  2. render phasereconcileSingleElement(div, Counter, <Counter person="Sarah" />)Both branches put a Counter as child #0. React only sees the position, not which line of JSX produced it.
  3. render phasekey null === null ✓ · type Counter === Counter ✓ → useFiberSame key, same type → reuse the fiber, hooks and all.
  4. render phaseCounter({ person: "Sarah" })Counter re-renders with the new props but the old state: Sarah inherits Taylor’s score.
  5. commit phasecommitTextUpdate("Taylor" → "Sarah")Only the name changes on screen. Usually not what you want.
current stepbeing comparedcreatedreused / keptupdated / moveddeletedwrong

0 / 0
State belongs to a position in the tree (plus type and key), not to the JSX that produced it.

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 a false hole 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

Sources

Primary source: legacy.reactjs.org/docs/reconciliation.html