Lists and keys

Render an array, choose a key that is stable, and understand what goes wrong when it is not.

06of 20
≈ 11Min read
8Parts

map returns elements

map returns an array; React renders each element in it. forEach returns undefined, which renders nothing — the commonest reason a list comes out blank.

an array of elements is a valid child
<ul>
  {doors.map((door) => (
    <li key={door.id}>{door.name}</li>
  ))}
</ul>

What a key is for

When the list re-renders, React has to match the new elements to the existing DOM nodes. The key is how it does that: same key means "this is the same item, update it"; a key that was not there before means "this is new, create it".

Without keys React falls back to position, which is fine until the order changes.

the index looks fine until it does not
{doors.map((door, i) => <Row key={i} door={door} />)}

// remove the FIRST door:
// before:  key 0 → Painted,  key 1 → Veneer
// after:   key 0 → Veneer
// React thinks item 0 changed its name, and REUSES the first row's DOM
// — so anything not derived from props stays: input values, focus, animation

Choosing one

KeyUse it when
item.idAlmost always. A database id, a uuid, a slug.
A composite: ` ${a}-${b} `No single field is unique but a pair is.
The value itselfA list of unique strings that never repeat.
The indexOnly when the list is static, never reordered, filtered or edited.
Never generate a key during render

key={Math.random()} or key={crypto.randomUUID()} gives every item a new key every render, so React throws away and rebuilds the whole list each time — losing focus, scroll and state, and doing the maximum possible work.

Where the key goes

on the outermost element of the map
// right
{doors.map((door) => <Row key={door.id} door={door} />)}

// wrong: the key is inside the component, not on the repeated element
function Row({ door }) {
  return <li key={door.id}>{door.name}</li>;
}

// with a fragment that needs a key, use the long form
{doors.map((door) => (
  <React.Fragment key={door.id}>
    <dt>{door.name}</dt>
    <dd>{door.days}</dd>
  </React.Fragment>
))}

Filtering and sorting

A filtered list is derived state: it is computed from doors and query. Keeping it in useState and updating it in an effect is the single most common mistake in React, and it guarantees the two will disagree at some point.

derive during render — do not store it
function Doors({ doors, query, sort }) {
  const shown = doors
    .filter((d) => d.name.toLowerCase().includes(query.toLowerCase()))
    .sort((a, b) => (sort === "days" ? a.days - b.days : a.name.localeCompare(b.name)));

  if (shown.length === 0) return <p className="empty">Nothing matches “{query}”.</p>;

  return <ul>{shown.map((d) => <li key={d.id}>{d.name}</li>)}</ul>;
}
sort mutates

doors.sort(…) sorts the prop array in place — the parent's data. Use toSorted, or [...doors].sort(…).

Empty and long lists

  • Always handle the empty case, and say what to do next.
  • A few hundred rows is fine. A few thousand needs virtualisation — render only the visible ones.
  • content-visibility: auto in CSS gets you a long way before reaching for a library.

Nested lists

Keys only have to be unique among siblings, not across the page.

keys are scoped to their own list
{groups.map((group) => (
  <section key={group.id}>
    <h2>{group.name}</h2>
    <ul>
      {group.doors.map((door) => (
        <li key={door.id}>{door.name}</li>
      ))}
    </ul>
  </section>
))}

A key can also force a reset

This is the one place where changing a key on purpose is the right answer — it says "this is a different thing now", and everything inside starts over.

the deliberate use
// changing the key unmounts the old component and mounts a fresh one,
// which is the cleanest way to clear a form when the subject changes
<EditForm key={selectedId} door={selected} />

Practice