Rendering Lists and Keys

Turning arrays into elements, and choosing keys that keep state attached to the right row.

What you'll learn

  • Rendering a list with map
  • What a key is for, and what React does without one
  • Why an index key can corrupt your UI, demonstrated
  • How to choose a key that will not bite

Rendering a list

There is no loop syntax in JSX. You turn an array of data into an array of elements with map, and JSX renders arrays:

const users = [
  { id: "u1", name: "Ada" },
  { id: "u2", name: "Grace" },
  { id: "u3", name: "Alan" },
];

export default function App() {
  return (
    <ul style={{ fontFamily: "sans-serif", padding: 16 }}>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Note the parentheses after the arrow: (user) => (<li>...</li>) returns the element. Braces there would be a function body returning nothing — the mistake from Arrow Functions.

What a key is for

React needs to match elements in the new render against elements in the previous one, so it can tell what moved, what was added, and what was removed. The key is that identity.

Without keys, React falls back to position — which is fine until the list changes order, at which point position is a lie.

Keys must be:

  • Unique among siblings (not globally)
  • Stable across renders — the same item keeps the same key
  • Given to the outermost element returned by the map

Why an index key is a trap

key={index} is the tempting default, and it is fine for a list that never reorders, never has items inserted, and holds no state. Otherwise it breaks in a way that is genuinely confusing to debug.

Here is the failure, side by side. Both lists render the same names. Type something into the first box of each list, then press "Add to front".

import { useState } from "react";

const initial = [
  { id: "u1", name: "Ada" },
  { id: "u2", name: "Grace" },
];

export default function App() {
  const [users, setUsers] = useState(initial);
  let nextId = users.length + 1;

  function addToFront() {
    setUsers([{ id: "new" + nextId, name: "New " + nextId }, ...users]);
  }

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <button onClick={addToFront}>Add to front</button>

      <p style={{ marginBottom: 4 }}><strong>key = index</strong></p>
      {users.map((user, index) => (
        <div key={index}>
          <input placeholder={user.name} style={{ marginBottom: 4 }} />
        </div>
      ))}

      <p style={{ marginBottom: 4 }}><strong>key = user.id</strong></p>
      {users.map((user) => (
        <div key={user.id}>
          <input placeholder={user.name} style={{ marginBottom: 4 }} />
        </div>
      ))}
    </div>
  );
}

In the first list, what you typed stays in the first box while the placeholder shifts down — your text is now attached to the wrong person. In the second, the text moves down with its row.

Nothing about the typed text is in users. It lives in the DOM, and React decides which input to keep by key. With key={index}, the first row is always key 0, so React reuses that input for whatever item is now first. With a real id, React knows the item moved and moves its input with it.

This is the same class of bug as mutating state: the data is right, and the screen is wrong.

Choosing a key

In order of preference:

  1. A stable id from your data — a database id, a UUID, a slug.
  2. A value that is genuinely unique and stable — an email, an ISBN.
  3. The index, only when the list is static: never reordered, never filtered, never inserted into, and its items hold no state.

Do not use Math.random(). A new key on every render tells React the item is brand new, so it throws away the DOM node and rebuilds it every time — losing focus, scroll position, and any animation, and doing it slowly.

Keys go on the outermost element

When the mapped item is a fragment, the key still has to go somewhere, so use the long form <Fragment key={...}>:

import { Fragment } from "react";

{users.map((user) => (
  <Fragment key={user.id}>
    <dt>{user.name}</dt>
    <dd>{user.role}</dd>
  </Fragment>
))}

Common mistakes

  • key on the wrong element. It goes on what map returns, not on a child inside it.
  • Index keys in a list that changes. The demo above is what happens.
  • Random keys. Every render looks like a brand-new list.
  • Expecting keys as a prop. key is for React; a component cannot read it. Pass an id prop as well if the child needs the value.

Exercise

This list renders as empty, silently — the map callback uses braces and never returns, so it produces undefined for every item and React renders nothing. There is no warning to tell you; an empty list is all you get.

Fix the return, then give each item a proper key.

const todos = [
  { id: "t1", text: "Learn map" },
  { id: "t2", text: "Learn keys" },
];

export default function App() {
  return (
    <ul style={{ fontFamily: "sans-serif", padding: 16 }}>
      {todos.map((todo) => {
        <li>{todo.text}</li>;
      })}
    </ul>
  );
}