Made by Dhawal Made by Dhawal

The Key Thief: why deleting one item ticks the wrong row in your React list

Kai ticks one quest, deletes it, and the next one shows up ticked. Chitragupta, keeper of Domlok's records, unmasks the Key Thief: index keys that tie state to a seat instead of an item.

Told by Raiden, with Kai asking the questions

The Key Thief, a grinning tanuki with a leaf on its head and a ring of stolen name-tag keys, beside the title "The Key Thief"

Chitragupta: My ledger does not say which quest is done, Kai. It says which key is done. You gave every quest a seat number, and the seats just moved.

I never touched Sweep the dojo!

If you delete one item from a React list and its checkbox, typed text or open state jumps onto the item below, your list is keyed by array index. React keeps state by key, so the state stays in the seat while the items shift. Key each item by a stable id and the bug disappears.

In the world of Domlok this is the work of the Key Thief, a trickster tanuki who swaps name tags for seat numbers. It never throws an error and never shows a warning. It just hands one item's state to another whenever the list changes order.

Your guide for this episode is Chitragupta, the Water guide of Domlok and keeper of every record. State, data and keys are his domain: he writes down what belongs to whom, which is exactly what a React key is for. Kai, as usual, is the one who handed the thief the seat numbers.

The tick that moved

Kai's quest board is a list of TodoItem rows. Each row keeps its own done state for the checkbox, and has a delete button:

QuestList.jsx
function TodoItem({ text, onDelete }) {const [done, setDone] = useState(false);return (<li>{" "}<inputtype="checkbox"checked={done}onChange={() => setDone(!done)}/>{" "}{text} <button onClick={onDelete}>Delete</button>{" "}</li>);}function QuestList() {const [todos, setTodos] = useState(initial);return (<ul>{" "}{todos.map((todo, i) => (<TodoItemkey={i}text={todo.text}onDelete={() => setTodos(todos.filter((_, j) => j !== i))}/>))}{" "}</ul>);}

KaiThe index is unique, so the key warning went away. Done, right?

Now Kai ticks Feed the koi and deletes it. Sweep the dojo moves to the top of the list, and it is ticked. Nobody touched it.

Why it happens: React matches rows by key

After every render, React compares the new list of children with the old one and matches them up by key. Same key means same component: React keeps its state and its DOM node, and only updates the props that changed.

KeyBefore deleteAfter delete (what React sees)
0Feed the koi, done: trueSweep the dojo, done: true (kept from key 0)
1Sweep the dojo, done: falseFix the gate, done: false
2Fix the gate, done: falsegone, so React unmounts this one

React didn't remove Feed the koi. From its point of view, key 0 and key 1 are still there with new text, and key 2 disappeared. So the tick that belonged to key 0 is now shown next to Sweep the dojo.

ChitraguptaAn index says where a row sits, not which item it is. The moment the order changes, every seat gets a new occupant and keeps the old one's state.

The same thing happens whenever the order changes:

  • Delete a row: its state slides onto the row below.
  • Add an item to the top: every row's state shifts down by one.
  • Sort or filter: checkboxes, typed text in inputs and open menus land on the wrong items.

KaiAnd React doesn't warn me?

No. Index keys are unique, so React is satisfied. If you leave the key out entirely, React uses the index anyway and only logs Each child in a list should have a unique "key" prop.

The fix: give every item a name

Give each item an id when it is created, store it with the item, and use it as the key. If the data comes from a database, use the database id. If you create items in the browser, crypto.randomUUID() works (it needs HTTPS or localhost).

QuestList.jsx
const addQuest = (text) =>setTodos([...todos, { id: crypto.randomUUID(), text }]);{todos.map((todo) => (<TodoItemkey={todo.id}text={todo.text}onDelete={() => setTodos(todos.filter((t) => t.id !== todo.id))}/>));}

Now deleting Feed the koi removes exactly that row. Sweep the dojo and Fix the gate keep their own keys, their own state and their own DOM nodes.

KaiFeed the koi is gone, and Sweep the dojo is still unticked!

Keys the thief loves

KeyWhat happens
key={Math.random()}A new key every render. React recreates every row and its DOM, so it is slow and anything the user typed is lost.
key={i}Breaks as soon as items are inserted, deleted, sorted or filtered.
key={todo.text}Works only while no two items share the same text. Duplicate keys confuse React again.
key={todo.id}Stable, unique among siblings, and it travels with the item. Use this.

Two more things worth knowing. Keys only need to be unique among siblings, not across the whole app. And your component never receives key as a prop: it is a hint for React. If the row needs its id, pass it separately.

QuestList.jsx
<TodoItem key={todo.id} id={todo.id} text={todo.text} />

Is the index ever fine? It is safe enough for a list that never changes order, never has items inserted or removed in the middle, and whose rows hold no state of their own, such as a static list of footer links.

Catch the Key Thief in DevTools

1. React DevTools. Open the Components tab and expand your list. Each row shows its key next to the component name. Tick the first row and select it: its state hook shows true. Delete it. If the selected TodoItem key="0" still shows true but its text prop is now a different item, the thief was here.

2. The Elements panel. Chrome briefly highlights DOM nodes as they change. With index keys, deleting the first row rewrites the text of every row below it and removes the last <li>. With stable ids, only the deleted <li> disappears and the others don't flash at all.

Before you merge a list

  • Does every element returned from map() have a key?
  • Does the key come from the data (an id), not from the position or from Math.random()?
  • Is the id created once, when the item is created, and stored with it?
  • After deleting, adding to the top and sorting, does every row keep its own checkbox and input text?

Acharya's Rule #11

Key each item by who it is, not where it sits.

Next episode: The Dialog Gate. Why a div modal is not really a modal, and what the native <dialog> element gives you for free.

Think you can outwit the tanuki yourself? Play Catch the Key Thief on domlok.in and earn seal 11.

Watch the 34-second version on YouTube at @dom-lok and save the carousel on Instagram at @mbd.domlok.

Further reading

Frequently asked questions

Why does deleting an item in my React list check the wrong checkbox?+
The list is probably keyed by array index. React keeps each component’s state by key, so when an item is removed the next item moves into the deleted item’s index and receives its state. Use a stable id from the data as the key instead.
Is it bad to use the index as a key in React?+
It causes bugs whenever items are inserted, deleted, sorted or filtered, because the index describes a position rather than an item. It is only safe for a list that never changes order and whose rows hold no state. If you give no key at all, React uses the index by default.
What should I use as a key in React?+
Use an id that belongs to the item, such as a database id. For items created in the browser, generate an id once when the item is created, for example with crypto.randomUUID(), and store it with the item. Keys must be unique among siblings and must not change between renders.
Can I use Math.random() as a React key?+
No. A random key changes on every render, so React cannot match the old and new rows. It recreates every component and DOM node each time, which is slow and loses anything the user typed.
How can I see React keys in DevTools?+
Open React Developer Tools, go to the Components tab and expand your list; each component shows its key next to its name. Select a row to see its props and hooks, then delete an item and check whether a key kept its old state with a different item’s props.