Skip to the content.

Keys in lists are identity, and using the index breaks it

Keys in lists are identity, and using the index breaks it

When you render a list, the key is not a formality to silence a warning — it is how the framework answers “which item is which” across two renders. Between the old list and the new one, React matches elements by key to decide what moved, what was added, and what was removed, then reuses the DOM and component state accordingly. Give each item a stable identity (its id) and reordering, inserting, and deleting all just work. Use the array index as the key and you have told React that identity is position — so when positions change, it attaches the wrong state to the wrong row, and you get lost input, misfired animations, and checkboxes that follow the slot instead of the item.

Index keys tie state to position; id keys tie state to the item A list is reordered. With index keys, the input state stays with the slot and ends up on the wrong item. With id keys, the state follows the item to its new position. key = index 0: Ann ✎typed 1: Bob reorder 0: Bob ✎typed 1: Ann edit landed on Bob — wrong item key = id #a1 Ann ✎typed #a1 Ann ✎ edit follows Ann to her new slot
Reorder the list: an index key keeps the typed text on slot 0 (now the wrong person); an id key carries the text with the item it belongs to.

The index says “identity is position”

Here is the bug in its natural habitat. The list has editable rows, and it uses the index as the key:

// index key: React thinks row 0 is always "the same" element
{items.map((item, i) => (
  <li key={i}>
    <input defaultValue={item.name} />   {/* DOM state keyed to POSITION */}
  </li>
))}

Prepend a new item and every existing item shifts down a slot. React, matching by key, believes the element at index 0 is unchanged — so the text you typed, the focus, and the DOM node all stay at index 0, now showing a different item’s data. Nothing looks obviously wrong until a user notices their input jumped rows.

A stable id says “identity is the item”

Give each row its real id and the matching becomes correct: React finds “item #a1” in both renders, knows it merely moved, and moves its DOM and state with it:

// id key: React tracks each item by its true identity across reorders
{items.map((item) => (
  <li key={item.id}>
    <input defaultValue={item.name} />   {/* state stays with the ITEM */}
  </li>
))}

Now inserting, deleting, sorting, and filtering all preserve per-row state and animations, because the key means what React assumes it means.

When the index is actually fine

The nuance worth keeping: an index key is harmless when the list is static — never reordered, never filtered, never inserted into except at the end, and its items hold no per-element state (no inputs, no local toggles, no animations). A render-once list of read-only labels can key by index without consequence. But the moment any of those conditions might change, the index is a latent bug, and “we might sort this later” is common enough that a stable id is the safe default. The rule: key by whatever is the item’s identity — a database id, a slug, a uuid — and reach for the index only for a provably static, stateless list. The data-table-sort exercise is the fastest way to feel this, because sorting is exactly the operation that turns an index key from “fine” into “why did my selection jump.”