This is one of those React gotchas that's been written about for years and still shows up constantly in real code, specifically because it looks completely correct and works fine in the exact scenario most people test.
The Setup That Looks Fine
// components/QueueList.tsx
'use client';
export function QueueList({ entries }: { entries: QueueEntry[] }) {
return (
<div>
{entries.map((entry, index) => (
<QueueRow key={index} entry={entry} />
))}
</div>
);
}
function QueueRow({ entry }: { entry: QueueEntry }) {
const [isEditing, setIsEditing] = useState(false);
const [note, setNote] = useState('');
return (
<div>
{isEditing ? (
<input value={note} onChange={(e) => setNote(e.target.value)} />
) : (
<span>{entry.name}</span>
)}
<button onClick={() => setIsEditing(!isEditing)}>Edit</button>
</div>
);
}
key={index} compiles fine, renders fine, and works completely correctly for a list that never reorders or has items removed from the middle. It's also a ticking bug for the exact moment that stops being true, which, for a live queue, a to-do list, anything backed by real-time or user-driven changes, is basically guaranteed to happen eventually.
What Actually Breaks
Say a user clicks "Edit" on the third row in a five-person queue, typing a note into that row's input. Then, before they finish, the first person in the queue gets called and removed from the list. React re-renders with four entries instead of five, and here's the actual bug, React uses the key to decide whether a given DOM node and its associated state represent the same conceptual item across renders or a genuinely new one.
With key={index}, the item that used to be at index 3 (where the user was editing) is now a completely different person's data, since everyone shifted up by one position after the removal. But React sees key={2} existed before and key={2} exists now, same key, so as far as React's reconciliation is concerned, this is the same component instance, not a new one. The isEditing and note state that belonged to the original row stays attached to whatever now occupies that same index. The user's open text input, and whatever they'd started typing, is now sitting on a completely different person's row, potentially about to submit a note meant for someone else onto someone else's queue entry.
Why This Is So Easy to Miss in Testing
Most manual testing during development involves a list that doesn't change while you're interacting with it, you open an edit field, you look at it, maybe you close it, and move on. The bug specifically requires the list to reorder or shrink while some item has open local state, exactly the kind of timing-dependent interaction that's easy to never happen to trigger during normal development testing, and completely likely to happen in production the moment two things occur close together in real usage, someone editing a note right as a queue updates in real time, for instance.
The Fix: Key by Something Stable and Unique to the Item
export function QueueList({ entries }: { entries: QueueEntry[] }) {
return (
<div>
{entries.map((entry) => (
<QueueRow key={entry.id} entry={entry} /> {/* stable, unique, tied to the actual item */}
))}
</div>
);
}
Using the entry's actual database ID, something that stays attached to that specific piece of data regardless of its position in the array, means React correctly tracks each row as the same conceptual item across reorders and removals. If the person originally at index 3 moves to index 2 after someone ahead of them is removed, React now correctly recognizes it's the same person, keyed by entry.id, not a coincidentally-matching array position, and their open edit state, their typed note, moves correctly along with them instead of staying pinned to a now-different row.
Where This Specifically Bites Harder: AnimatePresence
This gets worse, not just wrong but visually confusing, combined with exit animations. If you're using AnimatePresence for a list, covered in an earlier post on animation patterns, keying by index actively breaks the exit animation's ability to correctly identify which item is actually leaving.
// ❌ AnimatePresence can't correctly animate the actual removed item
<AnimatePresence>
{entries.map((entry, index) => (
<motion.div key={index} exit={{ opacity: 0 }}>
{entry.name}
</motion.div>
))}
</AnimatePresence>
// ✅ Correctly animates the specific item that was actually removed
<AnimatePresence>
{entries.map((entry) => (
<motion.div key={entry.id} exit={{ opacity: 0 }}>
{entry.name}
</motion.div>
))}
</AnimatePresence>
With an index-based key, removing the first item doesn't register as "index 0 left," it registers as "every remaining item shifted to a new key," which can cause the exit animation to fire on the wrong visual element, or fail to trigger the intended fade-out on the item that was actually removed at all.
The Actual Rule
Any list where items can be reordered, filtered, or removed from anywhere other than strictly the end needs a key tied to the item's own stable identity, not its position. A database ID, a UUID, anything genuinely unique and attached to that specific piece of data works. Array index is only ever safe for a list that's guaranteed static, never reordered, never filtered, items only ever appended at the end and never removed, a genuinely narrow set of real-world cases.
Go check any list in your own codebase using key={index}, specifically ones with any local state inside each item, an expanded row, an open edit mode, a checked checkbox, and check whether that list can ever reorder or have items removed from the middle. If it can, this bug is very likely already there, whether or not anyone's noticed yet. Drop what you find in the comments.
Get the templates: https://pixelanas.gumroad.com
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)