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
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.
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:
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.
| Key | Before delete | After delete (what React sees) |
|---|---|---|
0 | Feed the koi, done: true | Sweep the dojo, done: true (kept from key 0) |
1 | Sweep the dojo, done: false | Fix the gate, done: false |
2 | Fix the gate, done: false | gone, 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).
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
| Key | What 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.
<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 #11Key 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.