Course outline · 0% complete

0/28 lessons0%

Course overview →

Keys: how React tracks list items

lesson 5-2 · ~12 min · 14/28

Keys: how React tracks list items

When a list re-renders, React must match each new item to an old one to reuse DOM instead of rebuilding it. Position alone is unreliable, items get inserted, removed, and reordered. The key prop gives each item a stable identity:

{tasks.map(task => (
  <TaskItem key={task.id} title={task.title} />
))}

Rules for a good key:

  • Stable: the same item keeps the same key across renders. Database ids are ideal.
  • Unique among siblings: no two items in the same list share a key.
  • Not the array index, whenever the list can reorder, insert, or delete. If item 0 is deleted, every remaining item's index shifts by one, and React matches old state to the wrong items.
key=7 Waterkey=8 Rentkey=9 Momkey=8 Rentkey=9 Mombefore deleteafter deleteStable keys: React matches 8→8 and 9→9, reuses their DOM,and removes key 7. Index keys would mis-match every row.
Deleting the first item. With stable keys React pairs survivors correctly (gold lines) and drops only key 7. With index keys, old row 0 would be matched to the new row 0, the wrong item.

The index-key bug as a simulation

React keeps per-item component state in a table addressed by key. The list started as three tasks, the user checked the first row, then deleted it, and the two halves below show who inherits the check mark.

// React keeps per-item component state in a table addressed by key.
const stateByKey = { "0": "checked", "1": "unchecked" };

// The user deletes the first task. With INDEX keys the survivors shift:
const afterDelete = ["Pay rent", "Call mom"];
afterDelete.forEach((title, i) => {
  console.log(title + " -> " + (stateByKey[String(i)] || "unchecked"));
});

// With STABLE id keys, state stays glued to the item it belongs to:
const stateById = { "7": "checked" }; // id 7 was "Water plants", now deleted
const tasksWithIds = [
  { id: 8, title: "Pay rent" },
  { id: 9, title: "Call mom" },
];
tasksWithIds.forEach(t => {
  console.log(t.title + " -> " + (stateById[String(t.id)] || "unchecked"));
});

Output

Pay rent -> checked
Call mom -> unchecked
Pay rent -> unchecked
Call mom -> unchecked

In the first half, "Pay rent" moved into index 0, so it inherits the deleted task's checked state. That is the wrong task appearing checked, and no data was corrupted to make it happen, since only the lookup key changed meaning.

In the second half, no surviving task has id 7, so the old state matches nothing and simply disappears along with its item, which is exactly what you want.

The lookup table is the honest part of this simulation. React really does address per-item state by key, so a key that changes meaning between renders points at somebody else's state, and a key that stays put keeps state and item together.

Note how quiet the bug is. Nothing throws, the list length is right, the titles are right, and the only wrong thing is a check mark on a row the user never touched, which is the kind of report that arrives as "the app randomly checks tasks".

What index keys do to a checkbox

{tasks.map((task, i) => (
  <TaskRow key={i} task={task} />
))}

When the user checks the first row and then deletes the first task, the old row-0 state, meaning checked, is matched to the new first task, so the wrong task appears checked.

With key={i}, identity is the position. Delete task 0 and the former task 1 becomes the new key 0, so React hands it the old key-0 component state, check mark included.

The reason position is the wrong identity is that it describes where an item sits rather than which item it is. A task's position changes whenever anything above it is added or removed, and the task itself has not changed at all.

Stable ids like task.id keep state glued to the right item, because the id travels with the task no matter where it lands in the array. That is the whole fix, and it is one character of JSX.

Worth knowing which lists this bites and which it spares. The damage needs per-item state to misplace, so a list of plain text rows with index keys often looks fine, and the moment a row gains a checkbox, an input, or an expand toggle, the bug appears.

Choosing a key for chat messages

For deletable chat messages where each object has a unique server-issued id, the key is key={message.id}.

A server id is stable, since the same message keeps it forever, and unique among siblings, so React always matches old and new list entries correctly no matter how many messages are inserted or deleted.

It is also already on each message object, which is worth noticing because inventing a key is a warning sign. If nothing in the data identifies an item, that usually means the data model is missing an id rather than that a clever key expression is needed.

Two tempting alternatives fail the rules. The array index breaks the moment a message is deleted, and the message text breaks as soon as two people send the same word, since duplicate keys among siblings are exactly what the uniqueness rule forbids.

Note that keys only need to be unique within one list, not across the whole app. Two different lists can both use key={1} without any interference, because React matches keys among siblings only.

When an index key is acceptable

The array index is fine when the list never reorders, never inserts or removes in the middle, and items have no per-item state.

A static list that always renders in the same order, such as a fixed menu or a set of hard-coded tabs, is safe with index keys, because position genuinely is identity there.

All three conditions have to hold, and the third is the one people forget. A list that only ever appends still breaks if its rows hold state and anything is ever removed, and a reorderable list breaks immediately whether or not it has state.

ListIndex key safe
fixed nav menu, no stateyes
append-only log, no stateyes
sortable table rowsno
any list with checkboxes or inputsno
anything with a delete buttonno

The moment items can move or carry state, switch to stable ids. Since the cost of using an id is zero when one exists, the practical habit is to reach for the index only when the data has nothing else to offer and the list is genuinely static.