Keys Are Identity
What you'll learn
- What
keyreally means to React (hint: not performance) - Why the default behavior is silently "index as key", and when that lies
- The classic typed-text-shifts-to-the-wrong-row bug, fixed properly
- How to use
keyto force a component to reset, the official pattern
Last chapter ended with rule R4: keyed children are matched by key across positions. This chapter is about what that means, because key is the most misunderstood prop in React. Developers copy key={item.id} by ritual, get scolded about index keys, and never learn the one sentence that explains everything:
A key is a child's name. Two elements with the same key, under the same parent, are the same child, React will keep its fiber, its state, and its DOM node. A different key means a different child, even if everything else is identical.
Jargon: "key". A special prop you set on elements to declare a child's identity to React. It is stripped before props reach your component, you can never read
props.key, because it's a message to the reconciler, not to you.
The default: index as key
If you don't provide a key, React doesn't refuse, it quietly uses the child's position as its key. These two lists are identical to the reconciler:
// what you write
items.map((item) => <li>{item.label}</li>)
// what React effectively sees
items.map((item, index) => <li key={index}>{item.label}</li>)
For a list that never reorders, filters, or grows at the front, position is identity, and the default is truthful. The trouble starts when position and identity come apart, and the most common way is prepending.
The classic bug: prepend into a stateful list
Here is a note-taking list. Each row has an uncontrolled input, the browser owns the text, React never sees it. That's what makes the bug visible.
import { useState } from 'react';
let nextId = 4;
export default function App() {
const [rows, setRows] = useState([
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
{ id: 3, name: 'Linus' },
]);
function prepend() {
setRows([{ id: nextId++, name: 'New person' }, ...rows]);
}
return (
<div>
<button onClick={prepend}>Prepend a row</button>
<ul>
{rows.map((row, index) => (
<li key={index}>
{row.name}: <input placeholder={'Notes for ' + row.name} />
</li>
))}
</ul>
</div>
);
}
Try it: type hello into Ada's input. Click "Prepend a row".
What you see: the text hello is now sitting in New person's row. Ada's row is empty. The typed text "shifted" to the wrong row, even though your data is perfectly correct.
What happens, fiber by fiber:
- Before: fibers keyed
0, 1, 2(implicit index keys). Key0owns the DOM input containinghello, labeled Ada. - After the prepend, the new elements have keys
0, 1, 2, 3. - R4 matching: old key
0↔ new key0. Same key = same child. React keeps that fiber and its DOM node, and patches its props from "Ada" to "New person". Theplaceholderupdates; the label updates; but the DOM input node, which owns the uncontrolled texthello, is the same node, so the text stays. - Same story for keys 1 and 2: each keeps its DOM node (and its text) while receiving the next person's props.
- Key
3has no old partner → fresh fiber, fresh empty input, mounted at the bottom.
React did exactly what you told it to: "key 0 is the same child as before." The lie was in the keys, not in React. Index keys say position 0 is one eternal child whose data changes, but you meant Ada is Ada.
The fix: keys that name the data
Change one prop:
{rows.map((row) => (
<li key={row.id}>
{row.name}: <input placeholder={'Notes for ' + row.name} />
</li>
))}
Now replay the same experiment.
What happens, fiber by fiber:
- Before: fibers keyed
1, 2, 3. Key1(Ada) owns the input holdinghello. - After the prepend: new elements keyed
4, 1, 2, 3. - R4 matching by key: key
1↔ key1. Ada is still Ada. React keeps her fiber and DOM node, props unchanged, and moves the node down one position to match the new order (the watermark from last chapter decides the moves). - Keys
2and3likewise keep their nodes and their text. - Key
4has no old partner → fresh empty input, inserted at the top.
Every typed note stays glued to its person. Nothing about the diffing changed, only the names you gave the children. Keys didn't make it faster; they made it true.
What exactly a key preserves
When React matches old key ↔ new key, everything attached to the fiber survives:
- State, every hook cell (controlled inputs, counters, open/closed toggles).
- The DOM node, including things the browser owns and React never sees: uncontrolled input text, scroll position inside the row, media playback, canvas contents.
- Focus and selection, because focus belongs to the DOM node, and the node is kept.
Change the key, and all three are destroyed and rebuilt. Which is not just a failure mode, it's a tool.
Using key to force a reset
Since "different key = different child," you can weaponize key changes to reset state on demand:
import { useState } from 'react';
function ProfileEditor({ user }) {
const [draft, setDraft] = useState(user.bio);
return (
<div>
<h2>Editing {user.name}</h2>
<textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
</div>
);
}
const users = {
1: { name: 'Ada', bio: 'First programmer.' },
2: { name: 'Grace', bio: 'Found the first bug.' },
};
export default function App() {
const [userId, setUserId] = useState(1);
return (
<div>
<button onClick={() => setUserId(userId === 1 ? 2 : 1)}>
Switch user
</button>
<ProfileEditor key={userId} user={users[userId]} />
</div>
);
}
What happens: click "Switch user". The element at ProfileEditor's position now has key 2 instead of key 1. R4: different key = different child. React destroys the old fiber (the half-typed draft and all), builds a fresh one, and the new editor initializes from the new user. A clean slate, automatically.
This is the officially recommended "reset state when a prop changes" pattern. The common alternative is an anti-pattern:
// ANTI-PATTERN: syncing props into state
function ProfileEditor({ user }) {
const [draft, setDraft] = useState(user.bio);
useEffect(() => {
setDraft(user.bio); // reset when the user changes
}, [user.id]);
// ...
}
Why it's worse: the component first renders with stale state (Ada's draft briefly shown for Grace), then the effect fires and triggers a second render to fix it. Two renders, a visible flash of wrong data, a duplicated source of truth, and one more effect to forget when you add a second field. key={userId} does it in one render with zero effects, because it expresses the truth: a different user is a different editor.
Key rules: and the random-key trap
Three rules cover all correct usage:
- Stable. A key must name the data, derived from it (an id), never invented during render. Same item → same key across renders.
- Unique among siblings. Keys only need to be unique within their parent, different lists may reuse the same keys freely.
- Never random per render.
That third one deserves a demonstration, because it inverts a tempting idea. "Keys must be unique, and random values are extremely unique…"
// NEVER DO THIS
{rows.map((row) => (
<li key={Math.random()}>
{row.name}: <input />
</li>
))}
What happens: every render produces all-new keys. R4 finds no matches, every old fiber fails to pair, so React destroys the entire old list and rebuilds every row from scratch. On every render. Type one character in a parent input and the whole list is recreated: all uncontrolled text gone, focus gone, scroll positions gone, plus a pile of pointless DOM destruction and creation. key={Math.random()} doesn't mean "always unique" to React; it means "everything you knew is wrong, start over."
Diagram
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: Keys are a performance optimization. Reality: keys declare identity. Correct identity often also performs better, but the point is correctness, state and DOM nodes attached to the right data.
- Misconception: Index keys are always wrong. Reality: for static lists, no reordering, filtering, or prepending, and no per-row state, position is a perfectly truthful identity.
- Misconception: Keys must be globally unique. Reality: only unique among siblings. Two different lists may both have a key
1. - Misconception: You can read
props.keyinside the component. Reality: React stripskeybefore props are handed to you; it's a message to the reconciler only. - Misconception: Random keys are safe because they're unique. Reality: they change every render, so every child is destroyed and rebuilt every render, the worst of both worlds.
- Misconception: Key changes only matter in lists. Reality: a key change on any element forces a remount, that's the reset pattern.
Why it works this way
- Identity can't be guessed once order changes. Position-based matching is right until it isn't; only you know which data item a row represents, so only you can name it.
- State must follow data, not location. Users think in terms of items ("Ada's row"), not positions ("row 0"). Keys align React's bookkeeping with the user's mental model.
- A forced remount is the cleanest reset. Destroying and rebuilding guarantees no stale state can leak across, no effect ordering, no partial resets, no forgotten fields.
- Stripping key from props keeps concerns separate. Your component cares about its data; the reconciler cares about identity. Mixing them invites using keys as data, which lies twice.
Try it yourself
- Run the broken version of the notes app (index keys). Type in Ada's row, prepend, watch the text jump. Then switch to
key={row.id}and repeat, the text stays with Ada. One prop changed; the whole behavior flipped. - Hunt a second bug: give each row a
useStatetoggle (a "star" button) and repeat the prepend with index keys. Watch the stars shift rows too, it's state shifting, not just DOM text. - In the
ProfileEditorexample, removekey={userId}and logdrafton each render while switching users. Observe the stale draft surviving; restore the key and watch a clean re-initialization. - Try
key={Math.random()}on the notes list with uncontrolled inputs: type something, trigger any re-render, and watch your text vanish. Feel the cost of lying about identity.
Recap
- Key = identity. Same key + same parent = same child: fiber, state, and DOM node all preserved.
- No key means implicit index key, truthful for static lists, a lie for reordered or prepended ones.
- The classic bug: prepend with index keys → nodes kept by position → uncontrolled text and state "shift" to the wrong rows.
- A key preserves state, the DOM node (with everything the browser owns), and focus.
key={somethingThatChanged}is the official way to force a reset, one render, no effects, no stale flash.- Keys must be stable, unique among siblings, and never random:
key={Math.random()}rebuilds the entire list every render.