Keys and lists
Keys are React's identity mechanism for list items: stable unique keys let state follow the right row, index keys corrupt state on reorder/insert/delete, and a changing key intentionally forces a remount.
You map an array to a list of editable rows. A user types into the third input, then deletes the first row — and their text jumps to a different row. Nothing in your render logic moved it. You didn’t touch that input’s state. Yet the value is now attached to the wrong item, and you can’t find the bug in the JSX because the JSX is correct.
The bug is in the key. Most React developers treat keys as a lint-quieting formality — “React wants a key, here’s index, done.” But keys are how React decides which component instance is which across renders. Get them wrong and you don’t get a slow list; you get silent state corruption: focus, scroll position, uncontrolled input values, animation state — all attached to the wrong row. This lesson is about keys as an identity mechanism, not a warning to suppress.
After this lesson you can explain that a key is React’s stable identity for a list child across renders; predict exactly how an index key corrupts component state on reorder, insert, or delete; fix it with a stable id from the data; use a deliberately changing key to force a remount when you actually want to reset a subtree’s state; and recognise the opposite failure — a random key per render — that remounts everything every time, destroying both state and performance.
A key is the identity React uses to match a child to its instance between renders — not a cosmetic prop. When a list re-renders, React pairs the new elements with the previous ones by key, within the same parent. Matched keys reuse the existing component instance (and its state, DOM node, effects, focus); unmatched keys mount fresh or unmount. The position in the array is not the identity — the key is.
// React reads keys to answer: "is this the SAME row as last render, or a new one?"
{rows.map((row) => (
<EditableRow key={row.id} row={row} /> // identity = row.id, stable across reorders
))}So the rule “keys must be stable and unique among siblings” is not style advice — it’s the contract that lets React keep each row’s state glued to the right data.
Using the array index as the key ties identity to position, so any reorder/insert/delete reassigns state to the wrong row. With key={index}, the first item is always “key 0”. Delete the first row and the second row becomes key 0 — so React thinks the old first instance survived and just got new props. The component instance (with its local state) stays put while the data shifts underneath it.
// BUG: key = position. Local state (input value, focus) is pinned to a slot, not an item.
{items.map((item, index) => (
<Row key={index} item={item} /> // <input> inside Row holds uncontrolled value
))}This is invisible for static, append-only lists — which is why index keys pass review for so long. The corruption only fires the first time the list reorders, inserts at the front, or deletes from the middle. That delay is exactly what makes it a senior bug: it ships, then surfaces in production under real user editing.
Fix it with a key that comes from the data and never changes for that item — a database id, a UUID assigned at creation, anything stable. Now identity follows the item, not the slot. Reorder the array and React re-pairs each instance to its data by id, carrying the input value and focus along with it.
// FIX: key = the item's own stable id. Identity now travels with the data.
{items.map((item) => (
<Row key={item.id} item={item} />
))}
// if your data has no id, assign one ONCE at creation — never during render:
const add = (text: string) =>
setItems((prev) => [...prev, { id: crypto.randomUUID(), text }]);The id must be minted when the item is created and stored in the data, not computed in the render body. A key generated inside map is recomputed every render — which is the next failure mode.
Two deliberate uses of keys mark the boundary of correct usage — and one common abuse. Intentionally changing a key is a feature: when you give a subtree a new key, React unmounts the old instance and mounts a fresh one, resetting all its state. That is the idiomatic way to clear a form when the edited entity changes.
// INTENTIONAL remount: switching userId resets every field/scroll/uncontrolled input inside.
<ProfileForm key={userId} userId={userId} />The abuse is the inverse: a random key on every render. React never matches anything, so it tears down and rebuilds the whole list each render — losing all state and paying full mount cost continuously.
// ANTI-PATTERN: a fresh key every render → total remount each time.
{items.map((item) => (
<Row key={Math.random()} item={item} /> // never reuses an instance — state + perf gone
))}Index keys attach state to the wrong row; random keys attach it to no row. Stable ids are the only correct default — and a changing key is correct only when reset-on-change is the intent.
An editable to-do list: the index-key bug, then the stable-id fix. Each row has an uncontrolled <input>, so its typed value lives in the DOM, owned by the component instance — exactly the state that keys govern.
Before — index as key. Type “buy milk” into the second input, then delete the first row:
function TodoList() {
const [items, setItems] = useState([
{ id: "a", label: "first" },
{ id: "b", label: "second" },
{ id: "c", label: "third" },
]);
const remove = (id: string) =>
setItems((prev) => prev.filter((i) => i.id !== id));
return (
<ul>
{items.map((item, index) => (
<li key={index}> {/* ← position, not identity */}
<input defaultValue={item.label} /> {/* uncontrolled: value held by the instance */}
<button onClick={() => remove(item.id)}>x</button>
</li>
))}
</ul>
);
}Delete the first row and the array shifts: second is now index 0, third is index 1. React pairs the new index-0 element to the old index-0 instance — whose <input> still holds whatever was typed there. The text you typed into “second” stays on the top instance, now showing the wrong item. State stuck to the slot.
After — the only change is the key:
{items.map((item) => (
<li key={item.id}> {/* ← stable identity from the data */}
<input defaultValue={item.label} />
<button onClick={() => remove(item.id)}>x</button>
</li>
))}Now React pairs by item.id. When first is removed, instances b and c are re-matched to their own data — React unmounts exactly instance a, and every other input keeps the right value and focus. One-line change; the difference is whether identity tracks the item or the slot.
When you do want a reset — say a “duplicate row” button should hand the new row a blank input even though it shares a label — you give that row a fresh id, and the new key naturally mounts a fresh instance. Same mechanism, used on purpose.
▸Why this works
Why does this corrupt state rather than just rendering the wrong text? Because the things keys govern are the things React stores outside the render output: useState values, refs, uncontrolled DOM (input text, checkbox state, scroll position, <details> open state), running effects, and in-flight transitions/animations. The render output is always recomputed from current props, so the visible label will look right — but the instance-bound state stays welded to whichever instance React decided to reuse. That mismatch between “data the props describe” and “state the instance carries” is the entire bug class, and it’s why index keys are dangerous specifically on rows that hold their own state.
▸Common mistake
The opposite over-correction — key={Math.random()} or key={Date.now()} to “avoid stale rows” — is worse than the index key. A new key every render means React matches nothing, so it unmounts and remounts every row on each render: all local state and focus are lost continuously, effects re-run, the DOM is rebuilt, and you pay full mount cost on a list that never needed it. If you reach for a random key to force a refresh, what you actually want is a stable key plus correct props — or, for a single deliberate reset, a key derived from the entity (key={userId}), which changes only when a reset is genuinely intended.
A list of rows, each with an uncontrolled <input>, uses key={index}. A user types into the third row's input, then deletes the first row. What happens, and why?
A key is React’s identity for a list child across renders: within one parent, React pairs new elements to previous instances by key, and the matched instance keeps its state, DOM, focus, and effects. Keys must be stable and unique among siblings. An index key ties identity to position, so on any reorder/insert/delete the data shifts under the instances and state lands on the wrong row — silent corruption of uncontrolled inputs, focus, and scroll, not a slow render, which is why it survives review and surfaces only under real editing. The fix is a key from the data’s own stable id, minted once at creation, never computed in render. A deliberately changing key is a feature — it forces a remount to reset a subtree (e.g. key={userId} to clear a form). The opposite abuse, a random key every render, matches nothing and remounts everything, destroying state and performance. Default to stable ids; change a key only when reset-on-change is exactly what you mean.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.