Module 3: State and Interactivity
Rendering Lists and Why Keys Matter
Using map to render arrays, what a key actually does during reconciliation, and a live demonstration of why array index keys cause real bugs.
Rendering a list in React is just transforming an array of data into an array of elements, and map does exactly that.
const fruits = [
{ id: "a1", name: "Mango" },
{ id: "b2", name: "Guava" },
{ id: "c3", name: "Papaya" },
];
function FruitList() {
return (
<ul>
{fruits.map((f) => (
<li key={f.id}>{f.name}</li>
))}
</ul>
);
}
If you leave off key, React logs a warning. That warning is not cosmetic, and ignoring it leads to some strange bugs. So let's see what the key is for.
The problem keys solve#
Between two renders, React has to figure out what changed in the list so it can apply minimal DOM updates. Suppose a list goes from [A, B, C] to [B, C, A], in other words a move.
Without any identity information, all React can do is compare by position: slot 0 used to be A and is now B, slot 1 used to be B and is now C, slot 2 used to be C and is now A. Three updates. Fine for plain text, but not fine when each row holds state, a focused input or a video player.
A key gives each item an identity that survives reordering.
no keys (compare by position) with keys (compare by identity)
old: [A, B, C] old: [A, B, C]
new: [B, C, A] new: [B, C, A]
0: A→B update A moved from 0 to 2
1: B→C update B moved from 1 to 0
2: C→A update C moved from 2 to 1
→ React reuses the same three components
With keys, React keeps each component instance (and its state, and its DOM node) attached to the right item and just moves it.
What makes a good key#
A key must be unique among siblings and stable, meaning the same item always has the same key across renders.
- Best: an id that comes from your data, like a database id.
- If items have no ids, generate one when you create the item, and store it with the item (
crypto.randomUUID()in the "add" handler). - Not good:
Math.random()in the render. The key changes every render, so React throws every row away and rebuilds the whole list, along with all its state. - Usually not good: the array index.
Keys only need to be unique inside one list. Two different lists on the same page can reuse the same keys.
The index-as-key bug, live#
Using the index is tempting. It silences the warning. And it works fine as long as the list is static and never reordered, filtered or inserted into. As soon as it isn't, you get this:
function Row({ label, onRemove }) {
return (
<li>
{label} <input placeholder="note" />
<button onClick={onRemove}>x</button>
</li>
);
}
function Broken() {
const [items, setItems] = useState(["Apples", "Bananas", "Cherries"]);
return (
<ul>
{items.map((label, index) => (
<Row
key={index} // problem
label={label}
onRemove={() => setItems(items.filter((_, i) => i !== index))}
/>
))}
</ul>
);
}
Type "sweet" into the note field next to Apples, then delete Apples. The "sweet" text jumps to Bananas.
Why? After removal the list is [Bananas, Cherries]. The Bananas row is now at index 0. React sees key 0 before and key 0 now and says "same row, just update the label." The old row (with "sweet" typed into its uncontrolled input) is reused for Bananas. The label changed, but the DOM state that wasn't driven by props came along for the ride.
The fix is a stable id:
const [items, setItems] = useState([
{ id: 1, label: "Apples" },
{ id: 2, label: "Bananas" },
{ id: 3, label: "Cherries" },
]);
{items.map((item) => (
<Row key={item.id} label={item.label} onRemove={() => setItems(items.filter((i) => i.id !== item.id))} />
))}
Now deleting Apples removes the row with key 1, and the note field typed into row 2 stays with Bananas.
Where the key goes#
On the outermost element returned by the map callback, not inside the component being rendered.
// Correct: key on the element in the array
{users.map((u) => <UserCard key={u.id} user={u} />)}
// Wrong: key inside UserCard does nothing for the list
function UserCard({ user }) { return <div key={user.id}>...</div>; }
If you return a fragment from the callback and need a key, use the long form:
import { Fragment } from "react";
{rows.map((r) => (
<Fragment key={r.id}>
<dt>{r.term}</dt>
<dd>{r.definition}</dd>
</Fragment>
))}
Keys aren't props#
React uses key internally and doesn't pass it to your component. If the child needs the id, pass it separately as id={item.id}. Reading props.key gives you undefined (and a warning).
Filtering, sorting and empty states#
Do these on a copy, during render, without storing the result in state.
function ProductList({ products, query, sortByPrice }) {
let visible = products.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()));
if (sortByPrice) visible = [...visible].sort((a, b) => a.price - b.price);
if (visible.length === 0) return <p>Nothing matches "{query}".</p>;
return (
<ul>
{visible.map((p) => <li key={p.id}>{p.name} · {p.price}</li>)}
</ul>
);
}
[...visible].sort(...) avoids mutating. If visible came straight from products (when there's no filter), products.sort(...) would reorder the parent's array.
Notice that visible isn't state. It's computed every render from products, query and sortByPrice. That's the derived-values rule from earlier.
Where people get burned#
- Skipping keys and ignoring the console warning.
- Index keys on lists that can be reordered, filtered or edited. Bugs show up as inputs "swapping" values or animations replaying on the wrong row.
key={Math.random()}, which remounts everything on every render.- Putting the key inside the child component instead of on the mapped element.
- Duplicate keys, for example when two different API items share an id. React warns and renders weird results.
- Mutating the array with
sort()directly on a prop or state array.
Practice#
- Reproduce the index-key bug above, then fix it with ids.
- Build a list of 10 items with "move up" and "move down" buttons. Put an uncontrolled input in each row and watch whether text stays with its row after moving.
- Add a search box that filters a list and an empty-state message. Make sure the filtered list is not stored in state.
- Render a nested list (categories containing items) with proper keys at both levels.