
Why Keys Matter When Rendering Lists in React
Every React developer has seen the console warning: "Each child in a list should have a unique key prop." The quick fix is to add key={index}, the warning goes away, and everything seems fine. Then one day a user deletes the first item in a todo list and the checkbox on the second item flips, or an input shows text that belongs to a different row.
Keys aren't a formality to silence a warning. They're how React knows which item is which between renders. Get them right and React moves, adds, and removes rows correctly while keeping each row's state attached to the right data. Get them wrong and state ends up attached to the wrong item, often in ways that only show up with specific user actions.
In this post you'll see what keys do during reconciliation, reproduce the classic index-key bug, learn how to pick good keys, understand when index keys are actually fine, and use keys deliberately to reset component state. You'll also see the mistakes that keep showing up in code reviews.
What a Key Tells React
When React re-renders a list, it has an old array of children and a new array. It needs to decide, for each new child, which old child it corresponds to. That matters because the old child might have state, a DOM node with focus, or an uncontrolled input value attached to it.
Without keys, React matches children by position: the first old child with the first new child, the second with the second, and so on. With keys, React matches by key: the old child with key="a" becomes the new child with key="a", wherever it ended up in the array.
type Todo = { id: string; text: string };
export function TodoList({ todos }: { todos: Todo[] }) {
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}
If todos goes from [A, B, C] to [C, A, B], React sees that the keys are the same, just reordered. It moves the existing li DOM nodes instead of rewriting the text of all three. If an item is removed, React removes exactly that node.
This is part of the identity rules covered in understanding the virtual DOM and reconciliation: a component's identity is its type, its position, and its key. In a list, the key overrides position.
Reproducing the Index Key Bug
Text-only list items hide the problem, because if the text is updated correctly, it doesn't matter which DOM node shows it. The bug appears once list items have state, whether React state or DOM state like an input value.
Here's a list where each row has its own uncontrolled input and its own local state:
import { useState } from "react";
type Guest = { id: string; name: string };
function GuestRow({ guest }: { guest: Guest }) {
const [attending, setAttending] = useState(false);
return (
<li>
<label>
<input
type="checkbox"
checked={attending}
onChange={(e) => setAttending(e.target.checked)}
/>
{guest.name}
</label>
<input placeholder="Dietary notes" />
</li>
);
}
export function GuestList() {
const [guests, setGuests] = useState<Guest[]>([
{ id: "g1", name: "Ada" },
{ id: "g2", name: "Grace" },
{ id: "g3", name: "Linus" },
]);
function removeFirst() {
setGuests((prev) => prev.slice(1));
}
return (
<>
<button onClick={removeFirst}>Remove first guest</button>
<ul>
{guests.map((guest, index) => (
<GuestRow key={index} guest={guest} />
))}
</ul>
</>
);
}
Now try this:
- Check "Ada" as attending.
- Type "vegetarian" into Ada's notes.
- Click "Remove first guest."
You'd expect Ada's row to disappear. Instead, Grace's row now shows as checked with "vegetarian" in the notes, and the last row is gone.
Here's why. Before the removal, keys were 0: Ada, 1: Grace, 2: Linus. After, they're 0: Grace, 1: Linus. React matches by key:
- Key
0existed before and still exists. React keeps that component instance, withattending: trueand the "vegetarian" input, and just passes it the newguestprop: Grace. - Key
1keeps Grace's old instance and receives Linus. - Key
2disappeared, so React unmounts the last row, which held Linus's original state.
Nothing crashed, no warning appeared, and the data is now attached to the wrong person. Change the key to guest.id and the problem vanishes: key g1 disappears, so React unmounts exactly Ada's row, and Grace and Linus keep their own state.
{guests.map((guest) => (
<GuestRow key={guest.id} guest={guest} />
))}
The same thing happens on insertions at the start, sorting, and filtering. Any operation that changes which item sits at which index will misattach state when keys are indexes.
How to Choose a Good Key
A good key is stable, unique among siblings, and tied to the data item, not to its position or to the render.
Use IDs From Your Data
Most data already has one: a database primary key, a UUID, a slug, an email address. Use it directly.
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
Keys only need to be unique among siblings in the same array, not globally. Two different lists can both have an item with key="1".
Generate IDs When Data Is Created, Not When It's Rendered
If items are created on the client, like rows in a form or todos added by the user, assign an id when the item is created:
import { useState } from "react";
type Row = { id: string; label: string };
export function EditableRows() {
const [rows, setRows] = useState<Row[]>([]);
function addRow() {
setRows((prev) => [...prev, { id: crypto.randomUUID(), label: "" }]);
}
function removeRow(id: string) {
setRows((prev) => prev.filter((row) => row.id !== id));
}
return (
<div>
{rows.map((row) => (
<div key={row.id}>
<input
value={row.label}
onChange={(e) =>
setRows((prev) =>
prev.map((r) => (r.id === row.id ? { ...r, label: e.target.value } : r)),
)
}
/>
<button onClick={() => removeRow(row.id)}>Remove</button>
</div>
))}
<button onClick={addRow}>Add row</button>
</div>
);
}
crypto.randomUUID() is available in all modern browsers (on secure origins, which includes localhost). The id is stored with the data, so it's the same on every render.
Never Generate Keys During Render
This is worse than index keys:
// Wrong: a new key every render
{items.map((item) => (
<Row key={crypto.randomUUID()} item={item} />
))}
A new key means a new identity, so every row is unmounted and remounted on every render. All state is lost, inputs lose focus while typing, and performance tanks. Math.random() and Date.now() have the same problem.
Composite Keys
If no single field is unique but a combination is, join them:
{shifts.map((shift) => (
<ShiftCell key={`${shift.employeeId}-${shift.date}`} shift={shift} />
))}
Make sure the combination really is unique and stable. If two items could produce the same string, React will warn about duplicate keys, and behavior becomes unpredictable.
When Index Keys Are Fine
Index keys aren't always wrong. They're safe when all of these are true:
- The list is never reordered, filtered, or sorted.
- Items are never inserted or removed anywhere except the end.
- List items have no state, neither React state nor uncontrolled inputs.
A static list of navigation links or a read-only table rendered from a fixed array fits. When in doubt, use an id. It costs nothing when the list is static and saves you when it isn't.
Keys and Fragments
Keys go on the outermost element returned from map. If you return multiple elements per item, wrap them in a keyed Fragment. The short <> syntax can't take a key, so import Fragment:
import { Fragment } from "react";
type Term = { id: string; word: string; definition: string };
export function Glossary({ terms }: { terms: Term[] }) {
return (
<dl>
{terms.map((term) => (
<Fragment key={term.id}>
<dt>{term.word}</dt>
<dd>{term.definition}</dd>
</Fragment>
))}
</dl>
);
}
Also note that putting the key on an element inside the component doesn't help:
// Wrong: the key belongs on GuestRow in the map, not inside it
function GuestRow({ guest }: { guest: Guest }) {
return <li key={guest.id}>{guest.name}</li>;
}
React needs the key on the elements in the array it's reconciling, which are the GuestRow elements created by map. A key inside the component applies to a different, single-child position.
Keys Are Not Props
key is reserved. React uses it and doesn't pass it to your component, so props.key is undefined. If the component needs the id, pass it separately:
<GuestRow key={guest.id} id={guest.id} guest={guest} />
Usually you'll pass the whole item anyway, so this rarely matters, but it's a common confusion when someone tries to read key inside a child.
Using Keys to Reset State on Purpose
Keys aren't only for lists. Any element can have a key, and changing it tells React to treat the element as a brand new component: unmount the old instance, mount a fresh one with initial state.
That's the cleanest way to reset a component when the thing it represents changes. Consider a chat app where the message draft should be cleared when you switch conversations:
import { useState } from "react";
function MessageComposer({ contactId }: { contactId: string }) {
const [draft, setDraft] = useState("");
return (
<form
onSubmit={(e) => {
e.preventDefault();
console.log(`Send to ${contactId}:`, draft);
setDraft("");
}}
>
<textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
<button type="submit">Send</button>
</form>
);
}
export function Chat({ contactId }: { contactId: string }) {
return <MessageComposer key={contactId} contactId={contactId} />;
}
Without the key, switching from Ada to Grace would keep the half-written message meant for Ada, because it's the same component type at the same position. With key={contactId}, each contact gets a fresh composer.
The alternative is an effect that watches contactId and resets state, which renders once with the stale draft before clearing it and grows messy with more state. The key approach is shorter and has no flash. There's more on this in derived state in React: common mistakes and better patterns.
Use this deliberately, though. Remounting throws away all state and DOM in that subtree, including scroll position and focus, and re-runs effects. That's right for "a different thing is now shown," but wrong for "the same thing changed a little."
Keys and Animations
Animation libraries rely on keys to know which items are entering, leaving, or moving. With Motion's AnimatePresence, a stable key lets a removed item play its exit animation:
import { AnimatePresence, motion } from "motion/react";
type Toast = { id: string; message: string };
export function ToastStack({ toasts }: { toasts: Toast[] }) {
return (
<ul className="toasts">
<AnimatePresence>
{toasts.map((toast) => (
<motion.li
key={toast.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 80 }}
>
{toast.message}
</motion.li>
))}
</AnimatePresence>
</ul>
);
}
With index keys, removing the first toast would make the last one animate out while the others swap text. Stable keys make the right element leave. See animations in React with Motion for more.
Keys and Performance
Correct keys also help performance. When an item is inserted at the top of a 500-row list:
- With stable keys, React inserts one new DOM node and leaves the other 500 alone.
- With index keys, every row gets new props, so every row re-renders and every changed text node is rewritten, plus one new row is appended at the end.
Combined with memo, stable keys let unchanged rows skip rendering entirely, since each memoized row keeps receiving the same item object. With index keys, every row receives a different item after an insertion, so memoization can't help. For very long lists, pair stable keys with virtualization, covered in virtualizing long lists with TanStack Virtual.
Common Mistakes With Keys
- Using the index for dynamic lists. State and inputs end up on the wrong item after removals, inserts, or sorting.
- Generating keys in render.
crypto.randomUUID(),Math.random(), orDate.now()inmapremounts every row on every render. - Putting the key inside the child component. The key must be on the element returned from
map. - Using non-unique values. Names, titles, or prices often repeat. Duplicate keys cause React warnings and skipped or duplicated rows.
- Using a fragment shorthand in
map.<>can't take a key. UseFragmentwith a key. - Changing a key by accident. Keys built from values that change, like
${item.name}-${item.status}, remount the row whenever the status changes. - Reading
props.keyin the child. It's alwaysundefined. Pass the id as a separate prop.
Frequently Asked Questions (FAQ) About Keys in React Lists
Keys let React match each item in the new list with the same item in the previous render. That way it can move, insert, and remove the right DOM nodes and keep each component's state attached to the correct data, even when the order changes.
No. Index keys are fine for static lists that are never reordered, filtered, or changed except at the end, and whose items hold no state. For any list users can edit, sort, or filter, use a stable id from the data.
No. Keys only need to be unique among siblings in the same list. Two separate lists on the same page can reuse the same key values without any problem.
Assign one when the data is created or loaded, for example with crypto.randomUUID(), and store it with the item. A combination of fields that's guaranteed unique also works. Never generate the key inside the render.
The key is part of a component's identity. A new key tells React this is a different component, so it unmounts the old one and mounts a new one with fresh state. You can use that on purpose to reset a form or editor when its subject changes.
No. key is reserved by React and isn't passed as a prop. If the component needs the value, pass it again under another prop name like id.
Conclusion
Keys tell React which list item is which across renders. With stable keys from your data, React can move, insert, and remove rows precisely, and each row's state, input values, and focus stay with the item they belong to. Index keys work only for static, stateless lists, and keys generated during render break everything by remounting rows on each update.
Audit the lists in your app: anything that can be sorted, filtered, edited, or reordered should use an id, generated when the data is created if your data doesn't have one. Then look for effects that reset state when a prop changes and see whether a key can replace them. It's one of the simplest tools in React, and using it well removes a whole category of confusing bugs.


