Skip to content
C Codeloom
React

Rendering Lists and Keys in React

A practical guide to rendering lists in React with .map() — why the key prop matters, the difference between stable and index keys, and the bugs that come from getting keys wrong.

·10 min read · By Yash Kesharwani
Beginner 10 min read

What you'll learn

  • How .map() turns an array into JSX
  • Why React requires the key prop and what it is used for
  • The difference between stable keys and array-index keys
  • When using the index is actually fine
  • The classic "input loses focus" bug from bad keys
  • How to render empty lists, nested lists, and lists of fragments

Prerequisites

  • You understand components and JSX — see Components and JSX
  • Comfort with arrays, .map(), and arrow functions

Lists are everywhere. Comments on a post. Items in a cart. Rows in a table. React has exactly one pattern for them — .map() over an array — and exactly one rule that catches everyone the first time they break it: every element in a list needs a stable key. This post explains what keys are for, when the array index is safe, and the subtle bugs you avoid by getting keys right.

Mapping an array to JSX

JSX accepts arrays of elements. Any array. map() is the standard way to produce one.

function FruitList() {
  const fruits = ["apple", "banana", "cherry"];
  return (
    <ul>
      {fruits.map((fruit) => (
        <li key={fruit}>{fruit}</li>
      ))}
    </ul>
  );
}

A few mechanical points.

  • The .map() call lives inside { } because it is a JavaScript expression, not markup.
  • The callback returns one element per item. React renders the resulting array in order.
  • Each returned element needs a key prop. That is the rule we will spend most of this post on.

You can render anything from inside .map() — a built-in HTML element, your own component, a fragment. The only requirement is that the value at each position is renderable (an element, a string, a number, or null).

Why the key prop exists

When the array changes between renders, React needs to figure out which items are new, which moved, and which were removed. The key tells it.

Without keys, React falls back to position. If you insert an item at the top of a list, every following item appears to have changed — React would re-render all of them, and any internal state they hold (an input value, a CSS animation, a focused element) gets reset.

With stable keys, React can do the right thing: “the item with key="a" is still here, the item with key="b" is new, the one with key="c" got removed.” It moves DOM nodes instead of recreating them and preserves their state.

This is not a performance micro-optimisation. It is a correctness feature. Bad keys cause real, user-visible bugs.

The missing-key warning

If you forget key, React logs a warning in the console:

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

Treat that warning as a real bug. It is one of the few places where React’s runtime tells you exactly what to fix.

What makes a good key

A good key is:

  1. Unique among siblings. Keys only need to be unique within the same .map() call, not globally. A list of comments and a list of users can both use key={1} without conflict.
  2. Stable across renders. The same item should get the same key every time. A random UUID generated inside the render is the opposite of stable.
  3. Tied to the identity of the item, not its position. A id field from the database is the gold standard.
function CommentList({ comments }) {
  return (
    <ul>
      {comments.map((c) => (
        <li key={c.id}>
          <strong>{c.author}</strong>: {c.body}
        </li>
      ))}
    </ul>
  );
}

If your data does not come with an id, generate one when the item is created (not when it is rendered). For a static list of strings that you control, the string itself works as a key — as long as it is unique.

Why the index is usually wrong

The path of least resistance is to use the array index:

items.map((item, i) => <li key={i}>{item.name}</li>)

This compiles, the warning goes away, and… you might never notice the bug. Until the list reorders.

Imagine three items: A, B, C. Their indices are 0, 1, 2. Now insert a new item X at the top: X, A, B, C. Indices become 0, 1, 2, 3. From React’s perspective:

  • key 0 used to be A, now it is X — re-render
  • key 1 used to be B, now it is A — re-render
  • key 2 used to be C, now it is B — re-render
  • key 3 is new — mount C

Every item looks like it changed. Any state owned by the item — an input’s typed value, a checkbox’s checked state, an open/closed disclosure, focus — is now on the wrong item.

With proper IDs, React would have moved the existing A, B, C and mounted only X.

When index keys are actually fine

Index keys are acceptable when all three of these hold:

  1. The list is static — items are not added, removed, or reordered.
  2. Items have no internal state — they are pure displays of their props.
  3. The list is not filtered or sorted in a way that changes order.

A short, hard-coded marketing list. A footer’s set of links. A breadcrumb trail that always rebuilds from scratch. For these, key={i} is fine and adding a synthetic id is overkill.

The moment any of those assumptions could change — even in the future — switch to a real id. The cost is small. The cost of going back later, after a bug report, is much larger.

The classic input-focus bug

This is the most common way bad keys hurt users. A list of editable items uses index keys; the user reorders or removes one; the focused input now belongs to a different item.

// Buggy: index keys + items the user can remove
function TodoList({ todos, setTodos }) {
  return (
    <ul>
      {todos.map((todo, i) => (
        <li key={i}>
          <input
            value={todo.text}
            onChange={(e) => updateTodo(i, e.target.value)}
          />
          <button onClick={() => removeTodo(i)}>x</button>
        </li>
      ))}
    </ul>
  );
}

Delete the second item and the third item’s text now appears in what used to be the second input. The user typing in the third box suddenly sees their cursor jump.

The fix is the same as always: give every todo an id at creation time, and key on that.

{todos.map((todo) => (
  <li key={todo.id}>
    <input
      value={todo.text}
      onChange={(e) => updateTodo(todo.id, e.target.value)}
    />
    <button onClick={() => removeTodo(todo.id)}>x</button>
  </li>
))}

A tiny helper like crypto.randomUUID() (or any monotonically increasing counter) gives you ids cheaply.

Try it yourself. Build the buggy TodoList above with three items. Focus the third input, type a few letters, then delete the second item. Watch your text move to the wrong row. Switch to key={todo.id} and confirm the focus stays put.

Common warnings and what they mean

A few warnings show up around lists. Decoding them quickly is useful.

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

You forgot key on the element returned from .map(). Add one — usually key={item.id}.

Encountered two children with the same key, "X".

Two items in the same list have the same key. Either the source data has duplicates, or you are using a non-unique field. Find a field that is actually unique, or compose one (`${id}-${index}`) only if you have to.

Warning: Functions are not valid as a React child.

You forgot the parentheses or returned the function itself instead of the JSX. Often this is a typo: fruits.map(fruit => <li>) with no body, or fruits.map(fruit => followed by a newline that breaks the implicit return.

Empty lists

A .map() over an empty array returns an empty array, which renders as nothing. That is correct but not great UX — the user sees no list and no explanation. Combine with conditional rendering to handle the empty case explicitly.

function CommentList({ comments }) {
  if (comments.length === 0) {
    return <p className="muted">No comments yet. Be the first.</p>;
  }
  return (
    <ul>
      {comments.map((c) => (
        <li key={c.id}>{c.body}</li>
      ))}
    </ul>
  );
}

This is the same loading/error/empty/data pattern from the previous post. Empty is a real state.

Nested lists

You can nest .map() calls. Each inner list is its own scope, so its keys only need to be unique among its siblings — they do not have to be unique with respect to the outer list.

function Threads({ threads }) {
  return (
    <ul>
      {threads.map((thread) => (
        <li key={thread.id}>
          <h3>{thread.title}</h3>
          <ul>
            {thread.replies.map((reply) => (
              <li key={reply.id}>{reply.body}</li>
            ))}
          </ul>
        </li>
      ))}
    </ul>
  );
}

Each li has its own key in its own scope. There is no conflict between a thread keyed 1 and a reply keyed 1.

Fragments in a list

Sometimes each item needs to render multiple sibling elements — say a <dt> and a <dd> in a definition list. You cannot wrap them in a <div> without breaking the semantics. Use a keyed fragment:

function Glossary({ entries }) {
  return (
    <dl>
      {entries.map((entry) => (
        <Fragment key={entry.term}>
          <dt>{entry.term}</dt>
          <dd>{entry.definition}</dd>
        </Fragment>
      ))}
    </dl>
  );
}

The shorthand <>...</> cannot take a key, so when you need one, use the full <Fragment key={...}> form. Import Fragment from React.

Try it yourself. Render a list of users from https://jsonplaceholder.typicode.com/users with id as the key. Then add a button that shuffles the array on every click. Confirm the list re-orders without console warnings and that any inputs you place inside each row preserve their state across shuffles.

A small worked example

A sortable task list using id as the key, an empty state, and a conditional className.

import { useState } from "react";

function TaskList() {
  const [tasks, setTasks] = useState([
    { id: "a1", text: "Write blog post", done: false },
    { id: "a2", text: "Review PRs", done: true },
    { id: "a3", text: "Reply to email", done: false },
  ]);

  function toggle(id) {
    setTasks((ts) =>
      ts.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );
  }

  if (tasks.length === 0) {
    return <p>No tasks. Enjoy your day.</p>;
  }

  return (
    <ul>
      {tasks.map((task) => (
        <li
          key={task.id}
          className={task.done ? "done" : ""}
          onClick={() => toggle(task.id)}
        >
          {task.text}
        </li>
      ))}
    </ul>
  );
}

export default TaskList;

Stable id keys, an empty state, a conditional className, an immutable state update. Everything this post covers in one tiny component.

Recap

You now know:

  • .map() inside { } turns an array into a list of elements
  • Every element in a list needs a unique, stable key prop
  • A good key comes from the identity of the item — usually a database id
  • Index keys are fine for static lists with no internal state; otherwise they cause real bugs
  • The classic symptom of bad keys is focus, input value, or other component state ending up on the wrong row
  • Each .map() is its own scope — keys only need to be unique among siblings
  • Use <Fragment key={...}> when each item needs multiple sibling elements without a wrapper

Next steps

Lists usually exist so the user can do something with them — add, remove, edit. That brings us to forms. The next post covers controlled inputs in React, the value/onChange pattern, and how to handle a form with many fields without losing your mind.

→ Next: Controlled Forms in React

Questions or feedback? Email codeloomdevv@gmail.com.