React · lesson 3 of 22
Beginner
5Intermediate
12- 6 Effects and useEffect
- 7 You Might Not Need an Effect
- 8 The Rules of Hooks
- 9 useReducer and useContext
- 10 Custom Hooks
- 11 Measuring Re-renders: memo, useMemo, and useCallback
- 13 Routing with React Router
- 14 Portals with createPortal
- 15 Error Boundaries
- 16 Suspense and lazy
- 17 Reading Promises with use, No Framework Needed
- 18 Retrying After an Error with react-error-boundary
Advanced
5Also part of React
5Rendering Lists and the Key Warning
Render arrays with map, trigger React's real missing-key warning, then fix it — and see why an index key works but a stable id is safer.
By SkillByExample · Published
Rendering an array means calling .map to turn each item into an element. React asks every item to carry a key, and leaving it off produces a real, specific warning — not a vague “something’s wrong.”
The Missing-Key Warning Is Real, Not Hypothetical
Map over an array with no key, and renderToString prints React’s own warning before the markup.
// src/lists/ListNoKey.tsx
type ListNoKeyProps = { items: string[] };
export function ListNoKey({ items }: ListNoKeyProps) {
return (
<ul>
{items.map((item) => (
<li>{item}</li>
))}
</ul>
);
}
// src/lists/render-list-no-key.tsx
import { renderToString } from "react-dom/server";
import { ListNoKey } from "./ListNoKey.js";
console.log(renderToString(<ListNoKey items={["a", "b", "c"]} />));
$ npx tsx src/lists/render-list-no-key.tsx
Each child in a list should have a unique "key" prop.
Check the render method of `ListNoKey`. See https://react.dev/link/warning-keys for more information.
<ul><li>a</li><li>b</li><li>c</li></ul>
The warning prints to stderr, the markup to stdout — a terminal shows both interleaved, in the order they were written. The list still renders correctly; the warning is about what happens when this list changes later, not about what’s on the page right now.
Fixing It with a Stable Id
Pass key on the element each iteration produces, using something that identifies the item itself.
// src/lists/ListWithKey.tsx
type Todo = { id: number; text: string };
type ListWithKeyProps = { items: Todo[] };
export function ListWithKey({ items }: ListWithKeyProps) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
);
}
// src/lists/render-list-with-key.tsx
import { renderToString } from "react-dom/server";
import { ListWithKey } from "./ListWithKey.js";
const todos = [
{ id: 101, text: "a" },
{ id: 102, text: "b" },
{ id: 103, text: "c" },
];
console.log(renderToString(<ListWithKey items={todos} />));
$ npx tsx src/lists/render-list-with-key.tsx
<ul><li>a</li><li>b</li><li>c</li></ul>
Same markup, no warning. key never appears in the rendered HTML — React reads and removes it before the element reaches the DOM — so there’s nothing visible in the output to show it’s there; its only job is helping React tell items apart across renders.
The Array Index Also Works, with a Catch
The warning only asks for a key, not specifically a data id — the index satisfies it too.
// src/lists/ListWithIndexKey.tsx
type ListWithIndexKeyProps = { items: string[] };
export function ListWithIndexKey({ items }: ListWithIndexKeyProps) {
return (
<ul>
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
);
}
// src/lists/render-list-index-key.tsx
import { renderToString } from "react-dom/server";
import { ListWithIndexKey } from "./ListWithIndexKey.js";
console.log(renderToString(<ListWithIndexKey items={["a", "b", "c"]} />));
$ npx tsx src/lists/render-list-index-key.tsx
<ul><li>a</li><li>b</li><li>c</li></ul>
No warning, identical markup to the id-keyed version. renderToString only renders once, so this script can’t show what happens next — the risk with an index key is about what happens over several renders, not this one. The index is a position, not the item’s identity: react.dev’s own guidance is that if a list like this is later reordered, filtered, or has an item inserted in the middle, an index key can lead React to match old state (an input’s cursor position, which row was expanded) to whatever item now sits at that same index, rather than to the item that originally had it. A key derived from the data itself doesn’t have that failure mode, because an item keeps its key no matter where it moves.
Practice
1. Render both ListNoKey and ListWithKey with an empty array and confirm neither prints a warning.
Solution
import { renderToString } from "react-dom/server";
import { ListNoKey } from "./ListNoKey.js";
import { ListWithKey } from "./ListWithKey.js";
console.log(renderToString(<ListNoKey items={[]} />));
console.log(renderToString(<ListWithKey items={[]} />));
$ npx tsx practice1.tsx
<ul></ul>
<ul></ul>
.map on an empty array never calls the callback, so there’s no child element for React to warn about either way.
2. Render a scoreboard from a plain object (not an array) using Object.entries, keyed by name.
Solution
import { renderToString } from "react-dom/server";
function Scoreboard({ scores }) {
return (
<dl>
{Object.entries(scores).map(([name, score]) => (
<div key={name}>
<dt>{name}</dt>
<dd>{score}</dd>
</div>
))}
</dl>
);
}
console.log(renderToString(<Scoreboard scores={{ Ada: 10, Grace: 8 }} />));
$ npx tsx practice2.tsx
<dl><div><dt>Ada</dt><dd>10</dd></div><div><dt>Grace</dt><dd>8</dd></div></dl>
Object.entries turns the object into an array of [name, score] pairs, which is just another array to .map over — the name itself is a natural key since object keys are already unique.
3. Render a list of groups, each with its own list of items, keying both levels — including the same item name in two different groups — and confirm there’s no warning.
Solution
import { renderToString } from "react-dom/server";
function Groups({ groups }) {
return (
<div>
{groups.map((group) => (
<ul key={group.id}>
{group.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
))}
</div>
);
}
const groups = [
{ id: "fruit", items: ["apple", "pear"] },
{ id: "veg", items: ["apple", "carrot"] },
];
console.log(renderToString(<Groups groups={groups} />));
$ npx tsx practice3.tsx
<div><ul><li>apple</li><li>pear</li></ul><ul><li>apple</li><li>carrot</li></ul></div>
No warning, even though "apple" is used as a key twice. The two <li key="apple"> elements are never siblings of each other — one is inside the first group’s <ul>, the other inside the second group’s own <ul> — and React only needs keys to be unique among the siblings one .map call produces, not across the whole page. The outer key={group.id} is a separate, unrelated set of siblings one level up.