Skip to content
Chapter 23Lesson 2

Reconciliation and the key prop

How React's reconciliation algorithm decides what to change in the DOM, and how the key prop tells it which list items are which.

Picture a TodoList. It renders cleanly: every row shows the right text, the right checkbox state, the right half-typed note. Then a user clicks “sort,” the rows rearrange, and the checked boxes stay behind on the wrong rows. The note that was typed into the top item is now glued to a completely different todo. Nothing in the data is wrong; if you logged the array, you’d see every item in its correct new place. The bug is in how React matched the old rows to the new ones. That matching has a name: reconciliation . In the previous lesson you saw that rendering produces a tree of elements: run the function UI = f(state) and you get the tree. This lesson is about the step right after, where React compares the new tree to the last one and decides what to actually touch in the DOM. When reconciliation goes wrong, you get bugs that look impossible, because the data is fine and only the identity of each row is wrong.

Every render hands React a fresh tree. Re-running your component produces a whole new set of those plain element objects from the previous lesson: new <div>s, new <Row>s, top to bottom. React could throw away the existing DOM and rebuild it from that tree on every render, but that would be both wasteful and disruptive: every keystroke would wipe out focus, scroll position, and the browser’s input state. So instead it diffs. Reconciliation is React taking the new element tree, comparing it against the tree from the last render, and computing the smallest set of real DOM operations needed to bring the page in line with the new tree: create this node, patch that attribute, move this one, remove that one.

This sits exactly between the two phases you already know. Render produces the tree, and commit touches the DOM. Reconciliation is the step in the middle that decides what commit will do.

Trigger
Render
Reconcile
Commit
This lesson lives in the Reconcile box.

It helps to understand why React diffs the way it does. A perfect, fully general tree diff, one that asks “what is the minimum number of edits to turn this tree into that one?”, is a famously expensive problem. The textbook algorithm runs in roughly O(n³) time for n nodes. For a tree of a thousand nodes that is a billion operations on every render, which is far too slow. So React doesn’t attempt it. Instead it relies on two cheap heuristics that drop the diff to roughly O(n), linear and fast enough to run on every keystroke. The catch is that those heuristics are assumptions. They are right almost always, and when they are wrong React rebuilds more than it strictly had to. One of those wrong guesses is the exact bug from the intro, so the next two sections walk through both assumptions, and once you know them the bug is easy to spot in advance.

Two heuristics: same type updates, different type rebuilds

Section titled “Two heuristics: same type updates, different type rebuilds”

The first heuristic is about element type, and it’s the simpler of the two, so we’ll start here.

Different type means a different tree. If the element at a given position was a <div> last render and is a <section> this render, React doesn’t try to reconcile them. It tears down the entire old subtree, meaning that node and everything inside it, and builds a fresh one from scratch. Even if the children were byte-for-byte identical, they are gone and rebuilt. Anything held under the old node is discarded too: DOM state such as an input’s value or a scroll offset, and the React state of any component nested inside.

Same type means keep and patch. If the element was a <div className="a"> last render and is a <div className="b"> this render, React keeps the existing DOM node and just updates the one attribute that changed. It does not recreate the node. The node survives, and so does everything tied to it: its focus, its scroll position, and the React state of every component nested inside it.

Hold on to this takeaway, because it underpins the next three sections:

Keep the element type stable across renders and React preserves everything underneath it. Change the type and React resets everything underneath it.

That single rule is why a careless conditional can wipe out a form’s contents without warning, and later it’s the lever you’ll pull on purpose to reset state. For now, just hold the picture in mind.

Same type — reused
before
<div className="a">
<input>
after
<div className="b">
<input>
node kept · only className patched
Different type — rebuilt
before
<div>
<input>
whole subtree torn down
after
<section>
<input>
built fresh · child state & DOM reset
Same type at a position: the node is reused and only the changed attribute is patched, so the child survives. Different type: the whole subtree is thrown away and rebuilt, child state and all.

In real code, a type change usually hides inside a conditional that returns two different elements from the same spot:

const Panel = ({ expanded }: { expanded: boolean }) =>
expanded
? <section className="panel">{/* … */}</section>
: <div className="panel">{/* … */}</div>;

The two tags sit at the same position but are different types. Flipping expanded here doesn’t patch a <div> into a <section>; it throws the old subtree away and mounts a new one. That is harmless for a static panel, but it loses any state that was living inside it.

The type heuristic answers “is the thing at this spot the same kind of thing?” But it doesn’t answer the harder question: when a parent renders a list of same-type children, which old child corresponds to which new one? They’re all <Row>s, so type can’t tell them apart.

Without more information, React falls back to the only signal it has: position. The first child of the new render is matched to the first child of the old render, the second to the second, and so on down the list. Slot 0 to slot 0, slot 1 to slot 1.

The consequence of position-matching is where the bug comes from, so it is worth holding on to:

State and refs belong to the position, not to the data. “The component in slot 0” keeps its state across renders. If a different item moves into slot 0, it inherits whatever state slot 0 was holding.

When the list never reorders, this is exactly what you want and you’ll never notice it: slot 0 always holds the same item, so position and identity agree. But the moment items move between slots, position and identity disagree, and the state stays with the slot while the item moves away.

This is also why React warns you. When you .map a list without keys, the console prints “Each child in a list should have a unique key.” React is telling you that for a list that can change, position-matching is fragile, because it has no stable way to follow an item as it moves. You’ll see exactly why in the next section.

Three slots, one item each.

slot 0 Milk note← grab two
slot 1 Eggs
slot 2 Bread
Initial render. Slot 0 holds Milk plus the note you typed into it; each slot lines up with its item one-to-one.
new data Bread Eggs Milk
slot 0 Milk note← grab two
slot 1 Eggs
slot 2 Bread

Index keys say “slot 0 is still slot 0” — match by position.

You reverse the list. The data is now Bread, Eggs, Milk — but with index keys React still matches by slot index, not by item.

The note never moved. The labels did.

slot 0 Bread note← grab two
slot 1 Eggs
slot 2 Milk owned the note

stranded the note stayed on slot 0; Milk left without it.

Position wins. Slot 0 keeps its note and merely relabels to Bread, so your note is now stranded on the wrong item.

You saw the console warning, and the natural first move is to reach for the index.

items.map((item, i) => <Row key={i} item={item} />);

The warning goes away. But is the problem solved? Look closely at what key={i} actually says. Key 0 means “whatever is first right now,” and key 1 means “whatever is second right now.” The index isn’t an identity for the item; it’s an identity for the position. So key={i} still gives React no way to follow an item as it moves. It pins identity back to the slot, which is exactly what position-matching already did. You haven’t fixed anything, and you’ve made it worse, because the warning that would have flagged the problem is now gone and the bug ships silently.

Here is the canonical reproduction, worth following step by step. Build a list where each row contains an uncontrolled input , a plain <input> whose typed value lives in the DOM rather than in React state. Type a note into the first row, say ”← grab two”, then reverse the list. React matches by key, the key is the index, and the index is the position, so it decides slot 0 is still slot 0. The <Row> at slot 0 is the same type as before, so React keeps the DOM node, including the input and the text sitting inside it, and only patches the surrounding label to the new item’s name. Your ”← grab two” never moved. The label changed but the input didn’t, so the note now sits on a different item entirely.

It is invisible in prose and invisible in a screenshot, because it only shows up once you interact, so the demo below is the way to see it.

Type a note into the first row — say "← grab two". Then press Reverse and watch where your note ends up. The labels reorder, but your typed text stays in the same position, now attached to a different item. That's index-as-key matching by slot, not by item.

Preview

Notice what happened: your note stayed put while the labels slid past it. Nothing about the data is wrong, since the array reversed exactly as asked. The text came apart from its row because React followed the slot, not the item. The entire fix is a single line, shown side by side with the broken version below.

items.map((item, i) => (
<Row key={i} item={item} />
));

Re-pins identity to the slot. Key 0 always means “whatever is first now,” so reordering leaves state stranded on the position while the item moves on.

With key={item.id}, the key travels with the item. When you reverse the list, React sees that the first row’s item moved to the bottom, so it moves that DOM node there, input and typed note and all, instead of relabeling whatever slid into slot 0. Identity now follows the data, which is what you wanted all along.

There is one more reason this bug hides so well. Index keys only break when items change slots. They break on reorder, on filtering (removing an item shifts everything after it up a slot), and on inserting anywhere but the end. The one operation index keys survive is appending: a new item at the end gets a brand-new index, and nothing already in the list shifts. Most lists in early development only ever get appended to, so the demo works, the tests pass, and the feature ships. Then someone adds sorting, and the bug surfaces in production on a list that worked fine for months. So index keys aren’t visibly wrong until the day the list first reorders.

The fix generalizes into a rule you can apply to any .map: give each item a key tied to its data identity, something stable that travels with the item across renders. For rows backed by a database, that’s the primary key: key={item.id}. For content, it’s a natural slug: key={post.slug}. The key’s only job is to let React recognize “this is the same item I saw last render” no matter where it moved, and data identity is exactly what does that.

When there’s no natural id, as with items created entirely on the client where nothing from a server is available to key on, you assign one yourself. The discipline that matters comes down to a single word: once. Generate the id at the moment the item is created, store it on the item, and read it back every render thereafter.

const newItem = { id: crypto.randomUUID(), label, note: '' };
setItems((current) => [...current, newItem]);

crypto.randomUUID() is the browser primitive for a unique id. In this course’s projects the standard is a sortable UUIDv7 from the project’s uuidv7() helper, matching the database primary-key convention. Either way the point is the same: stamp it at creation, then leave it alone.

Contrast that with the version that looks almost identical but does the opposite:

const newItem = { id: crypto.randomUUID(), label, note: '' };
setItems((current) => [...current, newItem]);
// …elsewhere, on every render:
items.map((item) => <Row key={item.id} item={item} />);

Stable. The id is minted once and persisted, so it’s the same value every render, and React recognizes the row across reorders.

key={crypto.randomUUID()} inside the map mints a new key for every row on every single render. React compares this render’s keys to last render’s keys, finds zero matches, and concludes every row is a brand-new item, so it unmounts all of them and mounts fresh ones each render. State is wiped, focused inputs lose focus mid-keystroke, and a cheap attribute patch becomes a full teardown-and-rebuild of the entire list on every state change. This is the classic interview question “what’s wrong with this code,” and now you can answer it precisely: the key has to be stable across renders, and a value computed during render never is.

The rule cuts the other way too, so don’t over-correct into “index keys are forbidden.” For a list that is genuinely static, such as a fixed navigation bar, a constant menu, or anything that never reorders, filters, or inserts mid-list, the index is a perfectly stable key, because the index never changes either. The rule was never “index bad.” It is that a key must be a stable identity, and for reorderable lists the index isn’t one.

Two precise constraints tend to surprise people the first time they hit them, so it’s worth meeting them here rather than in a debugger.

The first: key is unique among siblings, not globally. A TodoList and a UserList can each render a child with key="1" and nothing breaks, because they’re different parents with different sibling sets, and React only ever matches keys within one set of siblings. You don’t need to namespace keys across your app or worry about collisions between unrelated lists. Being unique within the one .map is the whole requirement.

The second catches nearly everyone: key is not a prop you can read. It looks like a prop, sitting right there in the JSX next to your real props, but React reserves it and consumes it for reconciliation. Inside the child, props.key is undefined. If the child component actually needs the id for its own logic, pass it a second time under a different name:

<Row key={item.id} id={item.id} item={item} />

The same value now does two jobs: key goes to React and vanishes inside the component, while id is an ordinary prop the child reads like any other. Putting it all together, here is the canonical list-render pattern, walked through one decision at a time:

const InvoiceList = ({ invoices }: { invoices: Invoice[] }) => (
<ul>
{invoices.map((invoice) => (
<InvoiceRow key={invoice.id} id={invoice.id} invoice={invoice} />
))}
</ul>
);

A list component takes its data as a typed prop, an array of Invoice rows. Everything below is a function of this array.

const InvoiceList = ({ invoices }: { invoices: Invoice[] }) => (
<ul>
{invoices.map((invoice) => (
<InvoiceRow key={invoice.id} id={invoice.id} invoice={invoice} />
))}
</ul>
);

Map each data item to one element. This .map runs every render and produces a fresh array of <InvoiceRow> elements, which React will reconcile against last render’s.

const InvoiceList = ({ invoices }: { invoices: Invoice[] }) => (
<ul>
{invoices.map((invoice) => (
<InvoiceRow key={invoice.id} id={invoice.id} invoice={invoice} />
))}
</ul>
);

The key is the row’s data identity. React uses it, and only it, to match rows across renders, so a reordered or filtered list keeps each row’s DOM node and state with its item.

const InvoiceList = ({ invoices }: { invoices: Invoice[] }) => (
<ul>
{invoices.map((invoice) => (
<InvoiceRow key={invoice.id} id={invoice.id} invoice={invoice} />
))}
</ul>
);

key is consumed by React and unreadable inside the child, so the id is passed again as a normal id prop for the row’s own logic. One value, sent to two places.

1 / 1

Same component with a new key, plus what else triggers a remount

Section titled “Same component with a new key, plus what else triggers a remount”

You’ve now seen all three ways React can decide “the thing at this position is no longer the same thing.” It’s worth collapsing them into one rule, because they’re the same mechanism in three different forms, and that one rule is what you’ll reach for every time React seems to “lose” your state.

Start with the third form. When a component sits at the same position across two renders but its key changes between them, React stops treating it as the same instance. It runs the full unmount-and-mount cycle. The old instance is torn down: its state is gone, its refs detach, and (as you’ll see in the chapter on effects) its effect cleanup runs. A brand-new instance then mounts in its place, with fresh initial state and effects that run clean. It is the same component type but a different instance, because React deliberately threw the old one away when the key told it to.

Now fold in the two you already know, and the unified rule falls out:

The state under a position survives a re-render only if the element there keeps the same type and the same key (or, with no key, the same position). Change any one of those three, type, key, or position, and React unmounts the subtree and mounts a fresh one. That’s a remount, and a remount is a full reset.

Three triggers, one outcome. If you remember the outcome rather than the three triggers, “why did React reset my state?” becomes a question you can always answer: something about the element’s type, key, or position changed across the render.

The place this matters most is the innocent-looking conditional. Compare these two:

{showEdit
? <ProfileForm user={user} />
: <ProfileForm user={user} disabled />}

The <ProfileForm> instance is kept. It’s the same component type at the same position across both branches, with only the props changing, so any text the user has typed survives the toggle.

The takeaway is a design decision you’ll make constantly. If you want state to survive a conditional, keep the component type stable across both branches and let only the props differ. If you want state to reset, a key change is the tool for it. Changing a key on purpose to reset a component is a real, intentional pattern: it’s exactly how you’d reset a form when the user switches which record they’re editing. That tactic gets its own treatment later in this chapter, in “Remounting with key.” Here you only need the mechanism, key change means remount, and the knowledge that it’s a tool you’ll pick up shortly, not an accident to avoid.

One quick note on fragments, since they interact with all of this. Fragments (<>…</>) are transparent to reconciliation: their children reconcile as if they were direct siblings of the fragment’s parent, so wrapping things in a fragment doesn’t add a level of identity. The one wrinkle worth remembering is that the <> shorthand can’t take a key. When you need a keyed fragment in a list, say each item renders two sibling elements, write the long form instead: <Fragment key={id}>…</Fragment>.

Let’s make sure the unified rule has fused into one idea rather than three loose facts.

Each claim is about whether React preserves or resets the state at a position across a re-render. Mark each statement True or False.

Switching an <input type='text'> to a <textarea> at the same position keeps whatever the user had typed.

The element type changed (inputtextarea), so React tears down the old node and builds the new one — the typed value is gone. A type change at a position is a remount.

Toggling a disabled prop on the same <Form> component keeps its local state.

Same component type at the same position — only a prop changed — so React keeps the instance and just patches the prop. Same type, same key, same position: state survives.

Writing key={Math.random()} inside a .map preserves each row’s state across renders.

A fresh random key every render means no key matches the previous render, so React remounts every row on every render. State is wiped each time — the opposite of preserving it.

Changing a component’s key while it stays at the same position unmounts the old instance and mounts a fresh one.

That’s the remount mechanism. Same type, same position, but a changed key tells React this is a different instance — the old one is torn down and a new one mounts with initial state.

React 19’s reconciler: faster, same rules

Section titled “React 19’s reconciler: faster, same rules”

You’ll read posts and release notes about “the React 19 reconciler,” and it’s worth knowing what changed so you don’t go looking for new rules that aren’t there.

The two React documentation pages below are the canonical references for everything here. “Preserving and Resetting State” is the same type-position-key story from React’s own angle, worth reading to hear it explained a second way. “Rendering Lists” is the reference for keys. The other two go deeper. The legacy reconciliation doc is the original write-up of the diffing algorithm and its heuristics, and Dan Abramov’s essay rebuilds React’s whole model from first principles, including identity, reconciliation, keys, and remounting.