Skip to main content
ANVISoftware Solutions
Lesson 4 of 18Intermediate14 min

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.

EmployeeTable.tsx — a collection rendered from data
TSX
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.

The bug: rows with their own state, keyed by index
TSX
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:

  1. The list renders

    Aditi is key 0, Ben is key 1, Chen is key 2. Each row has an empty note input.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

TSX
// 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.

A list keyed by array index has rows that each hold their own draft text. The first item is deleted. What does the user see?
Why is crypto.randomUUID() a poor choice of key inside a map callback?

Saved in this browser only.