← Debugging Guides

阅读中文版本

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.

beginnerdebuggingreactkeysstate

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 -10

Interpretation

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 values

Interpretation

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.