Skip to main content
React · lesson 3 of 22
React beginner Lesson 3 of 22

Rendering 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

react lists keys

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.

Frequently Asked Questions

Why does React want a key on list items?
The key is how React tells items apart across renders, so it knows which DOM node to update, move, or remove instead of rebuilding the whole list from scratch. Without a stable key, React has no reliable way to track which item is which once the list changes.
Is the missing-key warning an error?
No, it is a console warning, not a thrown error — the component still renders and the markup is correct. It exists because an operation without keys that works today (such as a list that never reorders) can silently break once the list starts reordering, inserting, or removing items.
Can I use the array index as a key?
It silences the warning and works correctly for a list that never reorders, inserts, or removes items in the middle. It is risky for a list that does, because the index describes a position, not an item's identity, so the wrong state can end up attached to the wrong row after a reorder.
Where should the key go when I render nested lists?
Each level of mapping needs its own key, and it only has to be unique among that element's siblings — the ones produced by the same .map call. An outer map over groups keys on the group's id; an inner map over one group's items only needs to be unique within that group, since those items are never siblings of another group's items.