React List Item State Jumps to the Wrong Row - The key={index} Bug
7 min read
“You star the first book in the list. Then you add a new book to the top. The star doesn't stay on the book you starred - it jumps to whatever is now sitting in that same position.”
You star the first book in a reading list - a small per-row toggle, its own
local state. Then you click "Add Book," which puts a new book at the top of
the list.
The star doesn't stay with the book you starred. It moves - now it's on the
second book, the one that used to be first. The new book at the top is
unstarred, and the book you actually starred has lost its star entirely.
You didn't touch the star. The list reordered, and the star stayed in the
same position instead of following the same book.
Each BookRow keeps its own starred state internally, via its own
useState. When the list re-renders - because a book was added, removed,
or reordered - React has to decide, for each BookRow element in the new
list, whether it's "the same BookRow" as one from the previous render
(in which case its existing state carries over) or a brand new one (which
starts fresh).
That decision is made using key. Two elements with the same key, in the
same position in their parent's children, are treated as the same instance
across renders - same component, same internal state, carried forward.
key={index} means: "the BookRow at position 0 is always key=0, the one
at position 1 is always key=1," and so on - regardless of which book is
actually at that position. The key describes a slot in the list, not an
item in your data.
Before clicking Add Book: position 0 is "Clean Code," key=0, unstarred.
You click its star. Now position 0 (key=0) is starred - "Clean Code" is
starred, because "Clean Code" is position 0.
Click "Add Book." The array becomes ["New Book", "Clean Code", "The Pragmatic Programmer", "Refactoring"]. Now position 0 is "New Book," and
it's still key=0. React sees key=0 in both the old render and the new
one, and concludes: "same BookRow as before - carry over its state."
That state includes starred: true.
So key=0, now showing "New Book," renders with starred: true - the star
appears on the new book. "Clean Code" is now key=1, which is a different
key than it had last time (it didn't exist as key=1 before), so React
mounts it fresh, with starred: false. The star moved because the key it
was attached to moved - and the key was never attached to a book in the
first place.
Symptom
Star a row, then add, remove, or reorder items above it. The star (or
whatever per-row local state you're tracking) appears to "shift" to a
different row - specifically, to whatever item now occupies the position
the starred item used to be in.
Each book now carries a stable id, assigned once when it's created, that
never changes regardless of where the book sits in the array.
key={book.id} means React's "is this the same instance" question is now
answered in terms of which book this is, not which slot it's in.
Star "Clean Code" (id: 1). Click "Add Book" - a new book with id: 4
appears at the top. React sees key=4 for the first time (new instance,
starts unstarred) and key=1 still present (same instance as before,
starred: true carries over) - just now rendered in position 1 instead of
position 0. The star stays on "Clean Code," because the key stayed on
"Clean Code."
The Sensei's Hint
The fix is always the same shape: give each item a unique, stable
identifier when it's created - a database id, a counter, crypto. randomUUID(), anything that won't change for the lifetime of that item -
and use that as the key, instead of the array index.
Reordering. "Count Lands on the Wrong Row" is the same mechanism with a
"Move Up" button instead of "Add Book." Each row tracks how many times it's
been reviewed. Swap two adjacent rows with key={index}, and the positions
swap visually - but the review counts don't travel with the items, because
key=0 and key=1 are still attached to positions 0 and 1, just now
showing different items than before. The fix is identical: key={item.id}
instead of key={index}.
Deletion. "Notes Follow the Wrong Task" runs the same idea in reverse.
Each task has its own note input, tracked locally. Delete the first task,
and the array shortens - what used to be the second task is now at index 0.
With key={index}, React sees key=0 persisted across the delete and
carries over its state - the first task's note - onto the row now showing
the second task's label. The note didn't move with the task it belonged to;
it stayed attached to the slot.
In every case, the question is the same: "after this list change, does
key={index} still point at the same logical item it pointed at before?"
Prepend, reorder, and delete all answer "no" for at least one row - and
key={item.id} answers "yes" for every row, every time, because the key
travels with the data instead of the position.
Constraints
key={index} is fine - genuinely fine, not a code smell - for lists that
are rendered once and never reordered, filtered, or have items added or
removed from anywhere but the end. The moment a list can change shape in
the middle or at the start, and any item has its own local state (or
uncontrolled DOM state like focus or input values), key={index} will
eventually misattribute that state to the wrong item.
"Star Jumps to the Wrong Book" is the prepend case - the most visually
obvious of the three, since the star visibly relocates to a brand new item.
"Count Lands on the Wrong Row" swaps the order of existing items rather
than adding one, which is a useful second angle: nothing is created or
destroyed, yet state still ends up on the wrong row. "Notes Follow the Wrong
Task" closes the loop with deletion, which is often the hardest of the three
to predict by eye - work through it last, tracing exactly which key value
each row carries before and after the delete.