← React for Beginners

Rendering lists, and what keys are for

Lesson 05 of 12 · 10 min

Last lesson ended on a question. You can render one Panel by writing one <Panel />. What do you write when there are twenty of them and the list comes from data?

Nothing new. The answer is .map(), and then one small thing React asks for in return.

From an array to markup

Put some data at the top of App.jsx, above the components:

const notes = [
  { id: "a1", title: "Monday", body: "Started the course." },
  { id: "b2", title: "Tuesday", body: "Got as far as props." },
  { id: "c3", title: "Wednesday", body: "Lists, apparently." },
];

Then turn each one into a Panel:

function App() {
  return (
    <>
      <SiteHeader title="My first React app" />
      <main>
        {notes.map((note) => (
          <Panel key={note.id} title={note.title}>
            <p>{note.body}</p>
          </Panel>
        ))}
      </main>
      <Footer />
    </>
  );
}

Three panels. Add a fourth note to the array and there are four, without touching App.

.map() is the array method you already know. It is not a React feature and there is no special list syntax here. notes.map(...) returns a new array whose items happen to be JSX rather than numbers, and the braces around it are the same braces from lesson 3: evaluate this expression, put the result here.

JSX renders an array by rendering each item in turn. Put {[1, 2, 3]} in your markup and you get 123 on the page. That is the whole reason an array of JSX works: React already knew what to do with an array, and .map() produces one.

So map for a list is not a rule to memorise. It is two things you already had, used together.

The warning you will see immediately

Delete the key from that code and look at the console:

Each child in a list should have a unique "key" prop.

You will see this a great deal. It is worth understanding rather than silencing.

What a key actually does

Think about what React has to do when the list changes. It has last render’s description and this render’s, and it needs to work out what to do to the real DOM.

If the array went from three notes to four, has one been added at the end? Inserted at the front? Did the whole list get replaced with different content? Each of those means completely different DOM work, and from two arrays of similar-looking objects there is no reliable way to tell.

A key is you answering that question. It says this item, in this render, is the same thing as the item with that key in the last render. Given keys, React can match items up one to one, see that three of them are unchanged and one is new, and insert a single element. Without keys it falls back to matching by position, which is a guess.

Three rules follow from that, and there are no others:

  • Keys must be unique among siblings. Not globally unique. Two different lists can both use "a1"; two items in the same list cannot.
  • Keys must be stable. The same item must have the same key on every render. key={Math.random()} is the worst possible key, because every render says every item is brand new, and React throws away and rebuilds the entire list every time.
  • A key is not a prop. key is for React. The component does not receive it. If your component needs the id too, pass it again as a normal prop: <Note key={id} id={id} />.

Why the index is a trap

.map() hands you the index as a second argument, and it is right there, and it is unique, and it is stable in the sense that item 0 is always item 0. So this is the thing everybody reaches for:

{notes.map((note, index) => (
  <Panel key={index} title={note.title}>   {/* NO */}
    <p>{note.body}</p>
  </Panel>
))}

The warning goes away. Nothing appears to be wrong. And for a list that never changes order, never has anything removed from the middle, and is never filtered, nothing is wrong.

The problem is what the index actually identifies. key={index} does not say “this is the Monday note”. It says “this is the first note”. Those are the same statement right up until the list changes, and then they are opposites.

Delete Monday from the array and re-render. With key={note.id}, React sees the key a1 is gone and removes that panel. With key={index}, it sees three items became two, and that the item at key 0 used to say “Monday” and now says “Tuesday” - so rather than removing a panel, it edits the first two and deletes the last one. The result on screen looks identical either way, which is exactly the problem: the DOM elements are now attached to the wrong data.

That matters the moment those elements are holding anything of their own. Give each panel an input:

{notes.map((note, index) => (
  <Panel key={index} title={note.title}>
    <p>{note.body}</p>
    <input placeholder="your thoughts" />
  </Panel>
))}

The text you type into an <input> lives in the DOM element itself, not in your data. So picture typing hello into the box under Tuesday, and then Monday being removed.

Here is what happens to the three real <input> elements. Call them A, B and C, top to bottom:

With index keys React keeps the first two input elements and deletes the third, so typed text stays at its position while the labels shift up. With id keys it deletes the first and the other two move up carrying their contents. Before key={index} key={note.id} A · Monday empty B · Tuesday "hello" C · Wednesday empty A · Tuesday empty B · Wednesday "hello" C destroyed A destroyed B · Tuesday "hello" C · Wednesday empty The text never moved. The labels did. B still holds what you typed. With index keys B is now labelled Wednesday, and C - the last row - is gone.
The three input elements after Monday is removed. Shaded cells are the element holding the text you typed.

With ids, React sees the key a1 is gone, removes A, and B and C shuffle up carrying their contents. Correct.

With indexes, React sees three rows became two. It keeps A and B, rewrites their headings, and deletes the one on the end. Your hello never moved - it is still in B, exactly where you typed it. B is now labelled Wednesday. The text appears to have jumped down a row, and anything typed in the last row is gone altogether, because the element holding it was the one deleted.

The argument generalises. Everything with state of its own - inputs, checkboxes, scroll position, a video’s playback time, an open <details> - gets attached to the wrong row, or thrown away, the moment a list keyed by index changes shape. Two lessons from now your own components will have state, and it goes wrong in exactly this way.

You cannot run that experiment yet. Making the list change while the page is running needs state, which is lesson 7. Editing the array in your editor and saving will not show it either: that reloads the module, React rebuilds the whole tree, and every input is wiped, so both versions look identically empty and prove nothing. Lesson 7 ends with a button that removes a row. Come back here and try both keys once you have it.

Choosing a key

In order of preference:

  1. An id from your data. A database id, a slug, a filename. This is nearly always available and nearly always right.
  2. Something in the item guaranteed to be unique. An email address on a list of users, say. Fine, until two rows share one.
  3. An id you generate when the item is created - not when it is rendered. If the data has no id, give it one at the point you build the array, and it becomes case 1.

And index is acceptable in exactly one situation, which is worth naming so you do not feel guilty about it: a list that is static, never reordered, never filtered, and whose items hold no state. A hard-coded array of three footer links keyed by index is fine. Just be aware that “this list never changes” tends to stop being true.

Recap

.map() turns an array into an array of JSX, and JSX renders an array by rendering each item, so nothing about lists is React-specific until the key. A key tells React which item in this render is which item from the last one, so it can move and remove elements rather than guessing from position. Keys must be unique among their siblings and stable across renders, and the array index is neither of the things it looks like. It identifies a position, not a thing, and everything holding its own state gets stranded when those two stop agreeing.

Next: rendering something only some of the time, and the operator that puts a stray zero on your page.