React
Keys Are Identity, Not Index
Reading level
The to-do list that shuffles its inputs
You build a to-do list. User types in the first input field. You sort the list alphabetically. The text they typed doesn't move with the item — it stays in the first position. You prepend a new item. All the checked checkboxes shift down by one.
The bug isn't in your sort logic. It's in your key prop.
You used index as the key — and React thinks the first item is always
the first item, even when the data changes.
React uses the key prop as the identifier for a component instance across
renders. If an item's key doesn't change, React reuses its DOM node and state — even
if the item's content changed. Using array index as key means an item's identity is
its position, not its content.
The reconciliation algorithm is O(n) because it assumes keys are stable across the list. When keys are indices, any reorder, insert, or delete at position < end causes React to treat every subsequent element as changed — triggering unnecessary DOM updates and discarding existing DOM state (input values, focus, scroll position).
How React uses keys in reconciliation
React's reconciliation is like comparing two shopping lists. It tries to reuse items
that already exist rather than rebuild everything from scratch. The key
prop is the item's name tag — it tells React "this is the same item as before, just
update it" vs "this is a new item, create a fresh one."
When keys are indices:
- Item at position 0 is always "item 0" — even if a different todo moved there
- React reuses the input at position 0 — but it now belongs to a different todo
- The user's typed text stays with the position, not the todo item it belongs to
React's key-based diffing rule: elements with matching keys are updated in-place; elements with new keys are mounted fresh (existing DOM discarded); elements with removed keys are unmounted.
With index keys, inserting at position 0 means every subsequent element has a "changed" key — React updates all of them in-place rather than shifting them. Uncontrolled input values, CSS transitions in-progress, and scroll positions all belong to the DOM node, not to React state — so they don't follow the data when reused incorrectly.
The O(n) reconciliation assumption explicitly requires key stability: from the React reconciliation docs, "keys should be stable, predictable, and unique." Index satisfies uniqueness but fails stability (reorder) and predictability (insert). The algorithm's worst case with index keys degrades to O(n²) effective DOM operations on list mutations.
Three ways index keys bite
// ❌ Using index as key — common mistake
{todos.map((todo, index) => (
<TodoItem key={index} todo={todo} />
))}
Consequences:
- Uncontrolled inputs lose their content when items reorder
- Checkboxes shift when an item is prepended or inserted
- Animations break — enter/exit CSS transitions fire on the wrong element
The input value bug is the most confusing because it appears to be a React bug, not
a user error. The input IS controlled — but defaultValue on an input
is only read once at mount. If React reuses the input DOM node (because the key didn't
change), the defaultValue doesn't update — the DOM value stays.
The animation bug is harder to see: React reuses the element, so a "new" list item entering never fires a CSS enter animation — React thinks it was there all along.
The performance cost: with index keys, any mutation to the list head triggers
key: 0 through key: n-1 to all be "changed" keys on
subsequent renders — React diffs all of them against the new props even though
most data didn't change. Stable keys allow React to skip unchanged elements entirely.
Stable, unique keys from your data
// ✅ Use a stable ID from your data
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
// ✅ If there's no ID yet, generate one when creating the item
function createTodo(text) {
return { id: crypto.randomUUID(), text, done: false };
}
Now when the list reorders, each item keeps its key. React knows to reuse the right DOM node with the right input value and the right checkbox state.
What to use as a key:
- Database ID — always stable, always unique. Best choice.
- crypto.randomUUID() — stable within a session; generate once on item creation, not on render.
- Composite key —
{`${type}-${id}`}when IDs collide across types in the same list. - Content hash — for static, immutable lists with no IDs. Stable as long as content doesn't change.
What NOT to use: index, Math.random() on each render, Date.now() on each render.
One valid use of index: purely static lists with no reorder, filter, or sort — e.g.
a static navigation menu. Even then, a semantic key is clearer. The lint rule
react/no-array-index-key (eslint-plugin-react) flags index keys —
enabling it in your team's config prevents the pattern from accidentally entering codebases.
Pattern at a glance
Annotated example: React list key prop — index vs stable ID
❌ INDEX AS KEY
{todos.map((todo, index) => (
<TodoItem key={index} todo={todo} />
))}
Index key: item identity tied to position, not data
✅ STABLE ID AS KEY
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
Stable ID key: item identity follows the data
Try it: index key vs stable key
Type something in the first input. Click "Prepend item." In "Broken" mode, your text stays at position 0 — but the item it was attached to moved. In "Fixed" mode, your text moves with the item.
Notice the component mount/unmount behavior in fixed mode: prepending creates a new DOM node for the new item, and the existing nodes shift. In broken mode, all nodes are updated in-place.
Open DevTools Elements panel. Inspect the input elements' "id" attributes before and after prepending. In broken mode you'll see the same DOM nodes reused with different data props — that's the reconciliation reuse in action.
Showing: Fixed — stable ID key
Keys as a reset mechanism
Keys aren't just for lists — you can use them to intentionally reset a component's state. Change the key → React unmounts old, mounts new → all state resets.
// Reset a form when userId changes — no useEffect needed
<UserProfile key={userId} userId={userId} />
The key-as-reset trick is cleaner than managing a reset flag in state or firing a
useEffect to clear fields. It guarantees a fresh mount with no stale
state — useful for profile pages, wizard steps, and any "load a different entity"
pattern.
// Wizard: each step is a fresh form
function Wizard({ steps, currentStep }) {
const Step = steps[currentStep];
return <Step key={currentStep} />;
// key change on step change → Step unmounts + remounts → state resets
}
React's key-based identity contract: same key = same instance (state preserved, lifecycle continues); different key = new instance (state reset, full mount/unmount cycle). This makes key a lightweight "instance identity" escape hatch for cases where you need controlled identity without lifting state.
Enable react/no-array-index-key in eslint-plugin-react to prevent index
keys in codebases. Exception: truly static lists with no mutation — allow only with
inline comment documenting why.
References
Remember
Key takeaways
-
React uses key as an item's name tag — same key means "same item, update it"; different key means "new item, mount fresh."Index keys cause uncontrolled inputs, checkboxes, and animations to stick to position rather than move with their data item.Index keys degrade reconciliation from O(n) skips to O(n) updates on list mutations — stable keys allow React to skip unchanged items entirely.
-
Use a stable ID from your data as the key — not index, not Math.random(), not Date.now() on each render.Generate IDs with crypto.randomUUID() at item creation time, not render time — once generated, the ID should never change.Enable react/no-array-index-key in your lint config to prevent this pattern at the PR level; document intentional exceptions inline.
-
You can intentionally change a key to reset a component's state — this is cleaner than a useEffect reset for "load a new entity" patterns.key-as-reset is the idiomatic React pattern for wizard steps and profile pages — no useEffect needed to clear form state on entity change.Key is identity in the reconciliation algorithm — using it as a reset mechanism is leveraging the algorithm's contract, not fighting it.
Keep going
Finish this takeaway, then continue the track — Casey saved your spot locally.
Sign in with email to sync progress across devices (beta).
Inside the Casebook
New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.
No spam. Unsubscribe anytime. Emails sent via Buttondown.
RSS feed