React — Index as key Corrupts Row State
Fix React bugs where list rows show wrong state after reorder or deletion, understand why index keys break reconciliation identity, and use stable keys.
Symptoms
- After deleting a row, the next row inherits the deleted row input values, checkboxes, or local state
- Reordering items scrambles row-local state: the wrong row shows expanded content or edit mode
- Animations replay incorrectly or focus lands on the wrong row after list mutations
Possible causes
- key={index} ties component identity to position, so deletion shifts identities down the list
- Row-local state (useState in the row component) survives reconciliation and attaches to whatever now occupies that index
- Uncontrolled inputs inside rows keep their DOM value because React reuses the DOM node by positional key
- Server data lacks a stable identifier, so index became the convenient key
Diagnosis steps
Confirm the key usage
grep -Rn "key={" src/ | grep -E "index|\bi\]" | head -10Interpretation
Every key={index} on a mutable list is a latent state-corruption site; these lines are the migration surface.
Reproduce with delete-then-inspect
Test: render three rows with distinct inputs, remove the first, assert valuesInterpretation
Row two showing row one input after deletion is the exact reconciliation identity failure, deterministic and testable.
Check the data for stable ids
grep -Rn "interface.*Item\|type.*Item" src/types*Interpretation
The fix requires a stable identifier; if the API does not provide one, generate and persist it client-side at load time.
Root cause
React reconciles by key. With index keys, component identity equals list position: after deletion, the component that was row 2 becomes row 1, inherits row 1 state and DOM, and every row below inherits its neighbor. Row-local state therefore appears to migrate up the list after any mutation.
Fix
Use a stable, unique identifier from the data as key (id, uuid). When data has no identifier, generate one at load and keep it for the item lifetime. Keep index keys only for static, never-reordered lists, and hoist shared state when row identity is not needed.
Risk note
Random keys generated per render (key={Math.random()}) remount every row on every render, destroying state and performance. Stability across renders is the requirement.
Verify the fix
A test deletes and reorders rows and asserts each row keeps its own state; inputs, focus, and animations stay with their logical items.
Symptom walkthrough
The corruption follows positions exactly. Three rows with inputs “a”, “b”, “c”; delete the first; now the list shows “b”, “c” — but the first visible input contains “b” because the component that owned “b” kept its state while moving up one index. The mechanism is reconciliation identity:
{rows.map((row, i) => (
<Row key={i} ... /> // identity = position
))}
After deletion, React matches old index 1 to new index 1: same key, so same component instance — the instance that held “b” now renders the data of “c”. Delete a row and the next row inherits its checkbox; reorder and edit-mode lands on the wrong item. The bug is deterministic, which makes it fully testable.
Fix in practice
Identity from the data, not the position:
{rows.map((row) => (
<Row key={row.id} ... /> // identity survives any mutation
))}
When the API provides no identifier, mint one at load time and keep it for the item lifetime:
const rows = data.map((d) => ({ ...d, key: crypto.randomUUID() }));
// deletion filters rows; each remaining row keeps its own key and state
The anti-pattern to reject is key={Math.random()}: it changes every render, remounting all rows, resetting all state, and destroying focus — worse than index. The rule: same logical item, same key, every render.