Lists that melt your app

Lists fail in two ways: wrong keys make state “jump” between rows, and rerender cost explodes when each keystroke redraws the whole list. This produces subtle UX bugs and typing lag.

Break the invariant: unstable identity + full rerender

⚠️ Repro steps
  1. Type into "Row 1" and "Row 3".
  2. Click "Insert at top".
  3. Observe: typed values may appear in the wrong rows (identity broke).
  4. Now type again: notice the rerender counter spikes (performance broke).
Buggy
key = index (identity breaks) + full rerender
Buggy list
Inserting at the top shifts indices, breaking identity. When identity breaks, state can “jump” between rows.
Status
INVARIANT INTACT
Flips
0
Parent renders
0
Work (simulated)
3699
Detected issue
Status:Invariant intact
INVARIANT INTACT means the bug hasn’t been reproduced yet. Insert at top at least once to trigger the identity break.
Row 0
key = index
Row 1
key = index
Row 2
key = index
Row 3
key = index
Row 4
key = index
Row 5
key = index
Row 6
key = index
Row 7
key = index
Row 8
key = index
Row 9
key = index
Row 10
key = index
Row 11
key = index
Row 12
key = index
Row 13
key = index
Row 14
key = index
Row 15
key = index
Why this breaks
  • Keys define identity. React matches previous children to next children by key.
  • If you use index as key, insert/remove shifts indices → React reuses the wrong row instance.
  • That breaks the invariant: “row state belongs to the same logical item across renders.”
  • If the parent re-renders the whole list on every keystroke, work becomes O(N) per character.
  • Identity (keys) and update isolation (memo) are orthogonal. You need both to prevent list-wide re-renders.

Reference (correct mental model)

Incorrect / Buggy

// ❌ Index keys: identity is position, not the item items.map((item, i) => <Row key={i} item={item} />)
// ❌ Parent owns and re-renders all rows per keystroke setItems(prev => prev.map(...))

Correct

// ✅ Stable keys: identity follows the item items.map((item) => <Row key={item.id} item={item} />)
// ✅ Memo rows + stable callbacks to isolate updates const Row = memo(RowView) const onChange = useCallback((id, next) => { ... }, [])

Fix

This fix DOES change the UI behavior (values stop jumping and counters stabilize), so the fixed preview is shown.

Where the fix lives

The fix preview is embedded in the “Fixed” tab because the change is visible.

Key takeaways

  • Keys are identity. Never use index if the list can reorder/insert/remove.
  • State jumps are an identity bug, not a “controlled input” bug.
  • Typing lag often means “too many components re-render per keystroke.”
  • Stable keys + memoized rows (+ virtualization for large lists) form the baseline pattern.

Approach & tradeoffs

Approach

  • Give each row a stable id at creation time (not during render).
  • Use that id as key so identity survives inserts/reorders.
  • Memoize rows and keep props stable to avoid full-list rerenders.
  • Measure simple counters so regressions are obvious.

Tradeoffs

  • Memoization adds complexity: you must ensure stable props and avoid accidental changes.
  • Virtualization improves performance but changes UX (scroll behavior, find-in-page, variable heights).
  • For small lists, perf work may be premature — but stable keys are always correct.
Common pitfall

“Index is unique, so it’s a good key.”

Root fix: Uniqueness is not enough — keys must be stable across inserts/reorders. Use a stable item id.