AniUI Academy

Keys and List Reconciliation Pitfalls

A hands-on look at the exact failure mode index-based keys cause once a list becomes dynamic — state and focus attaching to the wrong row after an insertion, deletion, or reorder.

8 min read

The rendering-lists lesson introduced the rule: use a stable, unique key, not array index, for lists that can change. This lesson makes the failure concrete enough that you'll recognize it immediately in real code, because it produces no error message at all — just visibly wrong behavior.

The setup

A list of editable todo items, each with its own uncontrolled text input, keyed by index:

function TodoList({ todos, onDelete }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>
          <input defaultValue={todo.text} />
          <button onClick={() => onDelete(todo.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

defaultValue makes this an uncontrolled input — from the controlled-forms lesson, that means the DOM input node itself owns the current text after the initial render, not React state.

The exact sequence that breaks

  1. Todos are ["Buy milk", "Walk dog", "Call mom"], at positions 0, 1, 2.
  2. The user clicks into the input at position 1 ("Walk dog") and types "Walk dog urgently".
  3. The user deletes "Buy milk" (position 0). The array is now ["Walk dog", "Call mom"] — "Walk dog" has shifted to position 0, "Call mom" is now at position 1.
  4. React re-renders. With key={index}, it sees: position 0 existed before and still exists (matched, reused), position 1 existed before and still exists (matched, reused) — from React's perspective, nothing about identity changed, only the data passed to each position.

Because the DOM <input> node at position 1 is reused as-is (same key, same type, same position), its live, user-typed value — "Walk dog urgently" — stays exactly where it is. But position 1 in the data is now "Call mom". The result: the "Call mom" row visibly displays "Walk dog urgently", text the user typed into a completely different item. Nothing crashed, nothing warned — the UI is just quietly showing the wrong thing attached to the wrong row.

The fix

function TodoList({ todos, onDelete }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <input defaultValue={todo.text} />
          <button onClick={() => onDelete(todo.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

With key={todo.id}, deleting "Buy milk" (id, say, 1) means React sees that the element keyed 1 is simply gone — it correctly removes exactly that DOM node and its input's current value along with it, and moves nothing else. "Walk dog" (id 2) keeps its own key, its own DOM node, and its own typed text, regardless of what position it now occupies in the array.

It's not only about inputs

The same misattachment applies to any state tied to a list item's own component instance, not just an uncontrolled input's value:

  • A per-item "expanded/collapsed" useState inside a list item component.
  • A CSS transition or animation currently in progress on a specific row.
  • Scroll position inside a per-item scrollable area.
  • Focus itself — which element currently has keyboard focus.

All of it lives on the DOM node or component instance that a key ties together across renders — an unstable key can misattach any of it, silently, the same way it misattaches typed text.

Try it yourself

Type into one of the inputs, then delete the item above it — with index keys, watch the text jump to the wrong row. (Try changing key={index} to key={todo.id} and repeat the same steps to see the fix.)

Try it yourself
Loading playground...

What to remember

  • Index-based keys break down the moment a list's order or membership can change — a deletion or reorder can attach the wrong DOM node's leftover state to a different item.
  • The bug produces no error or warning: it's silently wrong output, which is exactly why it's easy to ship unnoticed.
  • A stable id key fixes it by telling React the true identity of each row, so state moves (or is removed) with the item it actually belongs to.
  • This applies to any per-item local state, not just inputs — expanded/collapsed toggles, animations, and focus all ride along with the same key-matched instance.

Check yourself

4 questions · pass 3/4 to unlock Stale Closures and Common Bugs

up to 50
  1. 1.A list of uncontrolled text inputs is keyed by array index. The user types into the second input, then the first item in the underlying data is deleted. What visibly happens?

  2. 2.Why doesn't this bug show up in a list that's rendered once and never reordered, filtered, or spliced?

  3. 3.What's the correct fix for the input-focus/value-attaching-to-the-wrong-row bug?

  4. 4.Besides input focus/value, what else can misattach to the wrong list item because of an unstable key?

4 left to answer