Reconciliation in Depth
reconcileChildFibers: two different algorithms for two different shapes
Section titled “reconcileChildFibers: two different algorithms for two different shapes”Rendering & Reconciliation proved the outside behavior: same type updates in place, different type remounts, and a stable key lets state follow the right list item. This lesson is about the actual code in packages/react-reconciler/src/ReactChildFiber.js that produces those results — and it is genuinely two different code paths, because “one child” and “a list of children” need different strategies.
For a single element child, reconcileSingleElement only has to compare against the one fiber already sitting there:
// packages/react-reconciler/src/ReactChildFiber.js — reconcileSingleElement (trimmed)function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) { const key = element.key; let child = currentFirstChild; while (child !== null) { if (child.key === key) { const elementType = element.type; if (child.elementType === elementType /* ... */) { // Same key AND same type: delete any other leftover children, // reuse this fiber, update its props. deleteRemainingChildren(returnFiber, child.sibling); const existing = useFiber(child, element.props); existing.return = returnFiber; return existing; } // Same key, DIFFERENT type: this key is claimed by something else now. // Delete this fiber and everything after it, fall through to create fresh. deleteRemainingChildren(returnFiber, child); break; } else { deleteChild(returnFiber, child); } child = child.sibling; } // No match found — create a brand new fiber. // ...}Read that middle branch carefully: matching key is not enough on its own. React checks child.elementType === elementType — literal reference equality on the type — in addition to the key match. Same key, different type still deletes and rebuilds. This is the exact mechanism behind the classic “I put a key on it, why did it still remount” bug, and it’s directly testable:
import { useEffect } from 'react';import { render, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('a component function defined INSIDE another component\'s render body remounts every render, even with a stable key', () => { const log: string[] = [];
function Parent({ tick }: { tick: number }) { void tick; // re-rendered with a new value each time, to force Parent to re-render // A new function object every call -> a new `type` every call. function Inner() { useEffect(() => { log.push('mount'); return () => { log.push('unmount'); }; }, []); return <p>inner</p>; } return <Inner key="looks-stable" />; }
const { rerender } = render(<Parent tick={0} />); log.length = 0; rerender(<Parent tick={1} />); // same key, but Inner is a NEW function this time
expect(log).toEqual(['unmount', 'mount']); // full remount, per reconcileSingleElement above});✓ a component function defined INSIDE another component's render body remounts every render, even with a stable key
Test Files 1 passed (1) Tests 1 passed (1)Reconciling an array of children: the key-map pass
Section titled “Reconciling an array of children: the key-map pass”A list is harder: items can be added, removed, and reordered all at once, and naively comparing every old child against every new child would be O(n²). reconcileChildrenArray avoids that with a specific two-pass strategy. First, it walks old and new children together, by position, as long as keys line up (fast path for “nothing reordered”). The moment a key mismatch shows up, it stops that fast walk and builds a lookup table from whatever old children are left — this is mapRemainingChildren:
// packages/react-reconciler/src/ReactChildFiber.js — mapRemainingChildren (trimmed)function mapRemainingChildren(currentFirstChild) { // Add the remaining children to a temporary map so that we can find them by // key quickly. Implicit (null) keys get added to this set with their index // instead. const existingChildren = new Map(); let existingChild = currentFirstChild; while (existingChild !== null) { if (existingChild.key !== null) { existingChildren.set(existingChild.key, existingChild); } else { existingChildren.set(existingChild.index, existingChild); } existingChild = existingChild.sibling; } return existingChildren;}That Map is the whole trick: once it exists, finding “is there an old fiber for this new element’s key” is an O(1) lookup, not a linear scan — which is what makes the overall pass O(n) instead of O(n²) for a reordered list. This is also, precisely, why a key has to be stable data identity and not the array index: mapRemainingChildren keys the fallback case on existingChild.index specifically for children that have no key — meaning “no key” and “index as key” produce the exact same lookup behavior. Using the index as an explicit key doesn’t opt you into anything smarter; it just re-implements what React already does by default when there’s no key at all, which is exactly what makes it fragile under reordering.
Placement and deletion: the flags this pass produces
Section titled “Placement and deletion: the flags this pass produces”Once a fiber is matched (by the key map or the fast walk), placeChild decides whether it needs to physically move in the DOM:
// packages/react-reconciler/src/ReactChildFiber.js — placeChild (trimmed)function placeChild(newFiber, lastPlacedIndex, newIndex) { newFiber.index = newIndex; const current = newFiber.alternate; if (current !== null) { const oldIndex = current.index; if (oldIndex < lastPlacedIndex) { // This existing fiber needs to MOVE forward in the DOM. newFiber.flags |= Placement; return lastPlacedIndex; } else { // This item's relative order didn't change — leave it alone. return oldIndex; } } else { // No alternate at all: this is a brand new insertion. newFiber.flags |= Placement | PlacementDEV; return lastPlacedIndex; }}Notice the fiber is reused either way (newFiber.alternate still points at the old one) — Placement here means “move this existing DOM node,” never “destroy and recreate it.” Anything from the old children that never gets matched to a new element goes through deleteChild/deleteRemainingChildren instead, which mark Deletion and stage the fiber to be torn down (state, effects, DOM node) in the commit phase. This split — reused-and-maybe-moved vs. genuinely-deleted — is directly observable: a matched fiber’s actual DOM node survives a reorder.
import { render, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('reordering a keyed list reuses the same DOM node — it moves, it is not destroyed', () => { function List({ items }: { items: string[] }) { return ( <ul> {items.map((id) => <li key={id} data-id={id}>{id}</li>)} </ul> ); } const { container, rerender } = render(<List items={['a', 'b', 'c']} />); const aBefore = container.querySelector('[data-id="a"]');
rerender(<List items={['c', 'a', 'b']} />); const aAfter = container.querySelector('[data-id="a"]');
expect(aAfter).toBe(aBefore); // same node instance, only its position in the DOM changed});✓ reordering a keyed list reuses the same DOM node — it moves, it is not destroyed
Test Files 1 passed (1) Tests 1 passed (1)Fragments have keys too, and the same rules apply
Section titled “Fragments have keys too, and the same rules apply”A <Fragment key={...}> (or the equivalent <React.Fragment key={...}> — the short <>...</> syntax cannot take a key) goes through reconcileSingleElement’s Fragment branch, which checks child.tag === Fragment instead of elementType, but otherwise follows the exact same “key must match, or this is a different thing” rule. A keyed Fragment wrapping several children behaves, for reconciliation purposes, exactly like a keyed element wrapping one — its state (and its children’s state) follows the key across a reorder:
import { Fragment, useState } from 'react';import { render, cleanup, act } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function Toggle({ label }: { label: string }) { const [on, setOn] = useState(false); return ( <button data-label={label} data-on={on} onClick={() => setOn(true)}> {label} </button> );}
function Group({ ids }: { ids: string[] }) { return ( <div> {ids.map((id) => ( <Fragment key={id}> <Toggle label={id} /> </Fragment> ))} </div> );}
test('a keyed Fragment carries its child\'s state across a reorder, exactly like a keyed element would', () => { const { container, rerender, getByText } = render(<Group ids={['a', 'b']} />); act(() => { getByText('a').click(); });
rerender(<Group ids={['b', 'a']} />);
const aButton = container.querySelector('[data-label="a"]'); expect(aButton?.getAttribute('data-on')).toBe('true'); // state followed "a" across the reorder});✓ a keyed Fragment carries its child's state across a reorder, exactly like a keyed element would
Test Files 1 passed (1) Tests 1 passed (1)flowchart TD
start["reconcileChildFibers(returnFiber, currentFirstChild, newChild)"] --> isArray{"newChild is an array?"}
isArray -->|"no — single element/Fragment"| single["reconcileSingleElement
check key AND type/tag"]
single -->|"key+type match"| reuse1["reuse fiber, update props"]
single -->|"mismatch"| rebuild1["delete old, create new fiber"]
isArray -->|"yes — a list"| fast["Fast walk: old[i] vs new[i]
by position, while keys line up"]
fast -->|"key mismatch found"| map["mapRemainingChildren
build key -> old fiber Map (O(1) lookup)"]
map --> place["placeChild per new element:
found in map -> reuse + maybe Placement
not found -> new fiber + Placement"]
map --> del["old fibers never matched -> deleteChild
(Deletion flag)"] Mental Model
Section titled “Mental Model”Reconciliation always asks the same question — “is the thing at this position the same thing as before, or something new” — but it answers it with different tools depending on shape. For one child, it’s a direct key-then-type check against the single fiber that was there. For a list, it’s the same check, just batched through a hash map so it stays linear instead of quadratic. Either way, “same” means reuse (and maybe move) the fiber; “different” means delete the old one and create a fresh one from scratch — there is no third option, no partial reuse of a “similar” fiber.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Separate single-element vs. array code paths | Each path is simple for its shape; no generic tree-diff overhead for the common “one child” case | Two algorithms to understand, not one |
Key-map (mapRemainingChildren) for reordered lists | O(n) overall instead of O(n²) — matters as list size grows | Only kicks in once the fast positional walk hits a mismatch; a fully-reordered list pays for building the whole map |
key+type both required to reuse a fiber | Prevents genuinely different components from accidentally inheriting each other’s state just because a key collides | A component that changes identity mid-life (rare, but real — e.g. swapping implementations behind the same key) always loses state, even if that swap was intentional |
Common Misconceptions
Section titled “Common Misconceptions”- “A matching
keyis enough to preserve a component’s state.” Proven false above: same key, differenttype(a fresh function object) still fully remounts —reconcileSingleElementchecks both. - “Reordering a list destroys and recreates the moved items.” Proven false above: the DOM node for a reordered-but-still-present item is the exact same object reference after the reorder —
placeChildmarks itPlacement(move), notDeletion+ a new insertion. - “Using the array index as
keyis at least better than no key.” They’re the same thing —mapRemainingChildrenfalls back toexistingChild.indexspecifically when there’s no key, so an explicitkey={index}recreates the exact behavior of having no key at all. - “
<>...</>and<Fragment key={...}>are interchangeable.” They aren’t for lists: the short syntax can’t carry akey, so it can’t participate in keyed reconciliation the way an explicit<Fragment key={...}>can — proven above.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a
.map()callback declare a nested component function (function Row() {...}orconst Row = () => {...}) that is then rendered with akey? Thekeydoesn’t help — the function itself is recreated every render, which is a newtype, proven above with a full remount. - Does a list use
<>...</>(the short Fragment syntax) inside a.map(), wrapping multiple children, with nokeyon it at all? That’s a silent version of the “no key” problem for a whole group of children at once — needs<Fragment key={...}>instead. - Does the code assume
key={index}is safe because “the list order never changes”? That’s only true until it does — and the failure mode (state landing on the wrong row) is proven above and in Rendering & Reconciliation, not a hypothetical. - Does a reviewer’s mental model assume reconciliation does a generic “similarity diff” between old and new children (finding the “closest match”)? It doesn’t — it’s an exact key-then-type match via a hash map, not fuzzy matching; a renamed or restructured key/type pair is always treated as entirely new, never adapted.
🐛 Spot the bug
function CommentList({ comments }) {return ( <div> {comments.map((c) => ( <> <p key={c.id}>{c.text}</p> <hr /> </> ))} </div>);}Show the answer
The key={c.id} is on the <p> inside the fragment, not on the fragment itself — and this uses the short <>...</> syntax, which cannot carry a key at all. React sees an unkeyed group of two children (<p> + <hr>) repeated for every comment; internally, mapRemainingChildren falls back to treating each of these fragments as keyed by its index (proven above to behave identically to no key), so inserting or removing a comment in the middle of the list mismatches every fragment after that point, in the same way an explicit index key would. The fix is <Fragment key={c.id}>...</Fragment> (the long form, imported from 'react'), which lets the outer group itself participate in keyed reconciliation.