Lists and Keys
By the end of this lesson
Render collections correctly with stable keys.
Most screens are lists. An employee directory, a course catalogue, the lessons in a module. In React you render one by turning an array of data into an array of elements, which is what Array.map already does.
React then asks for one extra thing: a key on each item. It looks like a formality, and it is the source of one of the most confusing bugs a React developer meets, because the symptom appears nowhere near the cause.
interface Employee {
id: string;
name: string;
department: string;
jobTitle: string;
}
export function EmployeeTable({ employees }: { employees: Employee[] }) {
if (employees.length === 0) {
return <p className="text-sm text-slate-600">No employees match these filters.</p>;
}
return (
<table className="w-full text-left">
<caption className="sr-only">Employees matching the current filters</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Department</th>
<th scope="col">Job title</th>
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<tr key={employee.id}>
<td>{employee.name}</td>
<td>{employee.department}</td>
<td>{employee.jobTitle}</td>
</tr>
))}
</tbody>
</table>
);
}- map returns an array of row elements, and React renders an array by rendering each item in order. There is no loop syntax because none is needed.
- The key goes on the outermost element produced by the callback — the tr, not the td inside it.
- employee.id comes from the data and identifies that person regardless of where they appear in the array. That is exactly what a key is supposed to be.
- The empty case is handled before the table. An empty result is a normal outcome of filtering, so it gets a sentence rather than an empty table with headings and nothing under them.
- scope="col" on the header cells and a caption give screen reader users the same structure sighted users get from the visual layout. The caption is visually hidden here because the heading above the table already says it.
What a key is actually for
When data changes, React renders the list again and compares the new elements with the previous ones. It has to decide, for each element, whether this is the same item as before — in which case the existing DOM node and any state inside it are reused — or a different item that needs a fresh node.
The key is how React answers that question. Same key means same item. Different key means a different item, so tear the old one down and build a new one.
This matters because a row is often more than text. It may hold a checkbox, an expanded panel, an uncontrolled input, scroll position, or focus. None of that lives in your data. It lives in the DOM and in the component's own state, attached to whichever element React believes is that row.
import { useState } from "react";
function EmployeeNoteRow({ employee }: { employee: Employee }) {
// State that belongs to this row and is not in the employee data.
const [note, setNote] = useState("");
return (
<li className="py-2">
<p className="font-medium">{employee.name}</p>
<label className="text-sm">
Note
<input value={note} onChange={(event) => setNote(event.target.value)} />
</label>
</li>
);
}
export function EmployeeNotes({ employees }: { employees: Employee[] }) {
return (
<ul>
{employees.map((employee, index) => (
// Wrong. The index describes a position, not a person.
<EmployeeNoteRow key={index} employee={employee} />
))}
</ul>
);
}- Each row owns a note that exists only in the component, not in the employees array. This is ordinary — selection, expansion and draft text all work this way.
- The key is the array index, so the first row is key 0 for as long as the list has a first row, whoever that happens to be.
- Nothing looks wrong on first render. Names line up with notes, and the code reviews cleanly. The failure needs the list to change.
Here is the failure in full, with three employees and index keys. Follow the note, not the name:
The list renders
Aditi is key 0, Ben is key 1, Chen is key 2. Each row has an empty note input.
A user types a note on Ben's row
"Handover to finance" is now held in the state of the component React knows as key 1.
Aditi leaves the company and is removed from the array
The new array is [Ben, Chen]. Ben is now at index 0 and Chen at index 1, so the keys become 0 and 1.
React compares the two renders by key
Key 0 existed before and still exists, so React keeps that component instance alive and passes it a new employee prop — Ben. Key 1 likewise survives and receives Chen. Key 2 has gone, so that instance is discarded.
The result on screen
Ben's row shows an empty note, because it is now driven by the instance that used to be Aditi's and never had a note typed into it. Chen's row shows "Handover to finance", because that state belonged to key 1. The note has moved to the wrong person.
The fix
Key by employee.id. Ben's key is unchanged by the removal, so React keeps his instance and his note with it, and discards Aditi's. The rendering code is otherwise identical.
Where a stable key comes from, in rough order of preference:
- A database identifier: employee.id, course.slug, lesson.slug. It came with the data and it does not change when the array does
- A natural unique field, if you are certain it is unique and stable. An email address usually qualifies; a person's name does not
- A composite of fields that together identify the row — a department slug plus a year, for example — built the same way every render
- An id generated once when the item is created, for data that has no server identifier yet: rows in a form the user is building up, or optimistic entries waiting to be saved
- The index, only for a list that is static, never reordered, never filtered, never added to, and whose items hold no state or focus. If you have to check all five conditions, use something else
Summary
- Render a collection by mapping data to elements; the key goes on the outermost element the callback returns
- A key tells React which item an element represents, so it can reuse the right DOM node and the right component state
- Index keys describe position, so reordering or deleting attaches state, focus and scroll position to the wrong row
- Prefer an identifier from the data; generate one at creation time when the server has not supplied one yet
- Holding selection in the parent keyed by id removes the problem at its source
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Build a small list of three employees where each row has a checkbox whose checked state lives in the row component. Key the list by index.
Tick the middle row, then add a button that removes the first employee from the array. Watch which row stays ticked.
Now change the key to the employee id and repeat. Nothing else in the code should change.
Show solution
With index keys, removing the first employee shifts everyone up, so the tick appears to move to a different person. With id keys it stays with the employee you ticked. The only difference between the two versions is the key, which is why this bug is so hard to find by reading the rendering code.
The deeper lesson is about where the state lives. The tick is not in the employees array, so React cannot recover it from your data — it can only preserve it by keeping the right component instance alive, and the key is the only information it has for that decision.
This is also an argument for lifting selection state up into the parent as a set of selected ids. Then the tick is derived from data that names the employee, and it survives any reordering regardless of keys. Both approaches are valid; the parent-owned version is harder to get wrong.
// The version that cannot go wrong: selection lives in the parent,
// keyed by employee id rather than by position.
export function SelectableEmployees({ employees }: { employees: Employee[] }) {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
function toggle(id: string) {
setSelectedIds((current) =>
current.includes(id) ? current.filter((item) => item !== id) : [...current, id]
);
}
return (
<ul>
{employees.map((employee) => (
<li key={employee.id}>
<label>
<input
type="checkbox"
checked={selectedIds.includes(employee.id)}
onChange={() => toggle(employee.id)}
/>
{employee.name}
</label>
</li>
))}
</ul>
);
}Think about it
Think about it
A colleague argues that index keys are fine in their list because it is read-only: just names and departments, no inputs, no selection, nothing stateful.
Are they right? What would have to change for the code to break, and how would that break reach production unnoticed?
Show solution
For that list as it stands, they are broadly right. With no state, no focus and no animation in the rows, mismatched instances produce the same output, so the bug has nothing to show.
It breaks the moment someone adds anything the data does not describe: a details toggle, an inline edit, a row animation, or a sort control. The person adding the toggle is thinking about toggles, not about a key written a year earlier, and the list still renders correctly until an item is removed or reordered.
That is why a stable key is worth using even when nothing depends on it yet. It costs one property today and removes a future bug that presents as "the wrong row is expanded" with no obvious connection to the line that caused it.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.