Skip to content
Chapter 23Lesson 2

Reconciliation and the key prop

How React's reconciliation diffs the DOM, and how the key prop tells it which list items are which.

Picture this scenario: a TodoList renders cleanly, every row showing 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 typed into the top item is now glued to a different todo. Yet the data is correct: log the array and every item sits in its right new place. The bug is in how React matched the old rows to the new ones, a step called reconciliation . Last lesson, rendering produced a tree of elements; this lesson is the step right after, where React compares the new tree to the previous one and decides what to touch in the DOM. When that matching goes wrong you get bugs that look impossible, because nothing is broken except the identity of each row.

Every render hands React a fresh tree: re-running your component rebuilds all the plain element objects from the previous lesson, new <div>s and new <Row>s, top to bottom. React could throw away the existing DOM and rebuild it from that tree every render, but that would wipe out focus, scroll position, and input state on every keystroke. So instead it diffs. Reconciliation compares the new tree against the previous one and computes the smallest set of real DOM operations that bring the page in line: create this node, patch that attribute, move one, remove another.

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

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

Why diff this way? A fully general tree diff, asking for the minimum number of edits to turn one tree into another, runs in roughly O(n³) time: for a thousand nodes, a billion operations on every render. Far too slow, so React skips it. Two cheap heuristics drop the diff to roughly O(n), fast enough to run on every keystroke. The catch is that heuristics are assumptions: right almost always, but when they are wrong React rebuilds more than it had to. One of those wrong guesses is the bug from the intro. The next two sections walk through both assumptions, and once you know them the bug is easy to spot in advance.

Same type patches, different type rebuilds

Section titled “Same type patches, different type rebuilds”

The first heuristic looks at element type.

Different type means a different tree. If the element at a position was a <div> last render and is a <section> this render, React doesn’t reconcile them. It tears down the old subtree, that node and everything inside it, and builds a fresh one. The children are rebuilt even if they were identical, and anything held under the old node is discarded: DOM state such as an input’s value or a scroll offset, and the React state of every nested component.

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 updates only the attribute that changed. The node survives, and so does everything tied to it: its focus, its scroll position, and the React state of every nested component.

This rule 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.

It’s why a careless conditional can wipe a form’s contents without warning, and later it’s the lever you’ll pull on purpose to reset state.

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 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 doesn’t patch the <div> into a <section>; it throws the old subtree away and mounts a new one. Harmless for a static panel, but it loses any state that was living inside it.

The type heuristic answers “is the thing in this spot the same kind of thing?” It can’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.

With nothing else to go on, React falls back to the only signal it has: position. The first new child is matched to the first old child, the second to the second, on down the list.

This is where the bug comes from:

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.

While the list never reorders, position and identity agree: slot 0 always holds the same item, so you never notice. The moment items move between slots they 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 position-matching is fragile for a list that can change, because it has no stable way to follow an item as it moves. The next section shows exactly why.

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 nothing is fixed. Key 0 means “whatever is first right now,” not “this item.” The index identifies the position, not the item, so key={i} pins identity back to the slot, exactly what position-matching already did. You’ve only silenced the warning that would have flagged the bug, so now it ships in silence.

Here is the canonical reproduction. Each row holds an uncontrolled input, a plain <input> whose typed value lives in the DOM rather than in React state. Type ”← grab two” into the first row, then reverse the list. The key is the index and the index is the position, so React decides slot 0 is still slot 0. Same component type, so it keeps the DOM node — input and text included — and only patches the label to the new item’s name. Your note never moved; it now sits on a different item.

The bug is invisible until you interact, so run the demo.

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

Your note stayed put while the labels slid past it. The array reversed exactly as asked; the text came apart from its row because React followed the slot, not the item. The fix is a single line.

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 strands state on the position while the item moves on.

With key={item.id}, React sees the first row’s item move to the bottom and moves that DOM node there, input and note and all, instead of relabeling whatever slid into slot 0.

This is also why the bug hides so well. Index keys only break when items change slots: on reorder, on filtering (removing an item shifts everything after it up a slot), and on inserting anywhere but the end. They survive appending — a new item at the end gets a fresh index and nothing shifts. Early lists usually only 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.

The fix generalizes into a rule for any .map: key each item by its data identity, a stable value that travels with the item across renders. For database-backed rows that’s the primary key, key={item.id}; for content it’s a slug, key={post.slug}. This is what lets React recognize the same item no matter where it moved.

When there’s no natural id, as with items created on the client, you assign one yourself, and the rule is once: generate the id when the item is created, store it on the item, and read it back every render after.

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

crypto.randomUUID() is the browser primitive for a unique id. This course’s projects instead use a sortable UUIDv7 from the uuidv7() helper, matching the database primary-key convention. Either way, stamp the id 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 tracks the row across reorders.

Minting a key during render is the classic “what’s wrong with this code” question, and the rule answers it: a key must 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, like a fixed navigation bar that never reorders, filters, or inserts mid-list, the index is a stable key, because the index never changes. A key must be a stable identity; for reorderable lists the index isn’t one.

Two constraints tend to surprise people the first time.

key is unique among siblings, not globally. A TodoList and a UserList can each render a child with key="1" without conflict, because React only matches keys within one set of siblings. You never need to namespace keys across the app; being unique within the one .map is the whole requirement.

key is not a prop you can read. It sits in the JSX next to your real props, but React reserves it for reconciliation, so inside the child props.key is undefined. If the child needs the id for its own logic, pass it again under a different name:

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

Now the same value does two jobs: key goes to React and vanishes inside the component, while id is an ordinary prop the child reads. Here is the canonical list-render pattern, one decision at a time:

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

The list 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 item to one element. This runs every render, producing a fresh array of <InvoiceRow> elements for React to reconcile against the last.

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 to match rows across renders, so a reordered or filtered list keeps each row’s DOM node and state.

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

Since key is unreadable inside the child, the id is passed again as a normal prop. One value, two destinations.

1 / 1

You’ve now seen all three ways React decides the thing at a position is no longer the same thing. They’re one mechanism in three forms, so it’s worth collapsing them into a single rule, the one you’ll reach for every time React seems to lose your state.

Take the third form. When a component stays at the same position across two renders but its key changes, React stops treating it as the same instance. It tears the old one down, its state and refs gone (and, as the chapter on effects will cover, its effect cleanup runs), then mounts a fresh instance with clean initial state. Same component type, different instance, because the changed key told React to throw the old one away.

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

This matters most in an innocent-looking conditional. Compare these two:

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

The <ProfileForm> instance is kept. Same component type at the same position across both branches, only the props change, so anything the user has typed survives the toggle.

So you have a design decision you’ll make constantly: to make state survive a conditional, keep the component type stable across both branches and let only the props differ; to make it reset, change the key. Changing a key on purpose is a deliberate tool, not an accident to avoid, and it gets its own treatment later in this chapter, in “Remounting with key.”

One note on fragments. Fragments (<>…</>) are transparent to reconciliation: their children reconcile as if they were direct siblings of the fragment’s parent, so a fragment adds no level of identity. The wrinkle is that the <> shorthand can’t take a key. When you need a keyed fragment in a list, write the long form: <Fragment key={id}>…</Fragment>.

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 type, same key, same position — only a prop changed — so React keeps the instance and patches the prop. 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 every time and wipes its state — 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.

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.