React

Lists and Keys in React: Why They Actually Matter

React keys are more than a way to silence a console warning. Understanding how React uses them during reconciliation reveals why the wrong key can intro...

The first time I saw the "Each child in a list should have a unique key prop" warning, I did what most people do: added key={index} to make it go away. The warning disappeared. Everything looked fine. I moved on.

That worked, until it didn't. A few months later I had a bug where deleting an item from a list caused a completely different item's input field to clear. It took me an embarrassingly long time to trace it back to index keys. Once I understood what React actually does with keys, the bug was obvious, and I stopped treating the key prop as a lint check to satisfy.

What React is trying to do

When you render a list, React needs a way to figure out what changed between renders. Without keys, it compares elements by position. If you have three items and you remove the first one, React sees that position 0 now has different content, position 1 has different content, and position 2 is gone. It updates every remaining element, even though you only removed one.

Keys give React a stable identity for each element. With good keys, React can say "the element with key abc is still here, but the element with key xyz is gone," and only update what actually changed. This is the reconciliation algorithm in practice, and it's why keys exist at all. They're not just a developer convenience, they're the signal React uses to match DOM nodes across renders.

// Without keys (or with index keys), React compares by position
const items = ["Apple", "Banana", "Cherry"];

// position 0: Apple -> position 0: Banana (React sees a change here)
// position 1: Banana -> position 1: Cherry (React sees a change here)
// position 2: Cherry -> gone (React removes this)

// React updated two nodes it didn't need to.

Why index keys cause real bugs

Here's the bug I hit. I had a list of form rows where each row had a text input. The user could delete any row, not just the last one.

function TodoList() {
  const [todos, setTodos] = React.useState([
    { id: 1, text: "Buy groceries" },
    { id: 2, text: "Write tests" },
    { id: 3, text: "Review PR" },
  ]);

  const remove = (id: number) =>
    setTodos((prev) => prev.filter((t) => t.id !== id));

  return (
    <ul>
      {todos.map((todo, index) => (
        // index key: the bug lives here
        <li key={index}>
          <input defaultValue={todo.text} />
          <button onClick={() => remove(todo.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

When the user deletes the first todo ("Buy groceries"), the list becomes [{ id: 2 }, { id: 3 }]. React sees:

  • Position 0 used to render id: 1, now renders id: 2
  • Position 1 used to render id: 2, now renders id: 3
  • Position 2 is gone

React thinks position 0's element survived, so it keeps the existing DOM node for that position, including whatever the user typed in the input. The new todo data ("Write tests") gets passed in as props, but the uncontrolled input still holds the old DOM value. The input displays the wrong text.

With proper keys based on ID, React correctly identifies that position 0's element is a new element, destroys the old DOM node, and creates a fresh one.

{todos.map((todo) => (
  <li key={todo.id}>  {/* stable identity, not position */}
    <input defaultValue={todo.text} />
    <button onClick={() => remove(todo.id)}>Delete</button>
  </li>
))}

When index keys are fine

Index keys are safe when all three of these are true:

  1. The list never reorders
  2. Items are never inserted or removed from the middle
  3. Items have no internal state (no inputs, no expanded/collapsed state)

A static list of read-only labels is a good example. The list never changes, so position is a stable identity. Using index there is harmless.

// This is fine: static, no internal state, never reorders
const tags = ["react", "typescript", "nextjs"];

return (
  <ul>
    {tags.map((tag, index) => (
      <li key={index}>{tag}</li>
    ))}
  </ul>
);

But if users can sort the list, or if items can be added, removed, or reordered, you need stable keys.

Choosing a good key

The best key is whatever uniquely and stably identifies the item across renders. Usually that's a database ID or UUID.

// Database records: use the ID
{posts.map((post) => (
  <PostCard key={post.id} post={post} />
))}

// Items without IDs: generate one when creating the item, not during render
const [items, setItems] = React.useState(() =>
  initialItems.map((item) => ({ ...item, id: crypto.randomUUID() }))
);

One mistake I've seen is generating keys inside the render function:

// Don't do this
{items.map((item) => (
  <Item key={Math.random()} item={item} />
))}

This gives every item a new key on every render. React sees no matching keys from the previous render, tears down every element, and recreates the entire list from scratch. You lose component state on every update and kill any performance benefit reconciliation was meant to give you.

Keys and component state

Keys do something else that's worth knowing. You can use a key deliberately to reset a component's internal state.

function ProfileEditor({ userId }: { userId: string }) {
  const [name, setName] = React.useState("");

  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

// When userId changes, the old ProfileEditor is destroyed and a new one mounts fresh
<ProfileEditor key={userId} userId={userId} />

Without the key, React sees the same component in the same position and keeps the instance. The name state stays from the previous user's profile. Adding key={userId} tells React this is a conceptually different instance, so it unmounts the old one and mounts a new one with clean state.

This technique works anywhere you want a "fresh start" without adding a reset function or a useEffect to clear state when props change.

The mental model shift

Once I understood that keys are about identity across renders, not just list rendering, everything clicked. The warning isn't telling you to add a prop to make the linter quiet. It's telling you that React can't figure out which element is which, and it's guessing based on position. Sometimes that guess is right. When it's wrong, you get bugs that are hard to reproduce and harder to explain.

The rule I follow now: if a list can change in any way, use a stable ID. If items don't have IDs, assign one when creating them. Index keys are a shortcut that works in narrow circumstances and creates hard-to-diagnose bugs everywhere else.

← Older
Props, Children, and Component Composition in React
Newer →
Lifting State Up in React

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.