Contents
8 Chapter

Lists and key

The pattern of mapping an array to components, the meaning of key, and concrete examples of how things break when you use the index as key.

In Chapter 7 we covered the patterns for rendering the screen differently based on conditions. In this chapter we cover another essential topic — how to render multiple pieces of data at once — and the special prop that inevitably comes with it, key.

key is not just a naming convention; it is a tool tied directly to the reconciliation algorithm we cover in Chapter 14 (Performance). Lock down the basics of key in this chapter and Chapter 14 reads lightly.

How to render an array on screen #

When the data to render is an array, use the map method to transform each item into JSX and embed the result inside JSX as-is.

src/FruitList.jsx
function FruitList() {
  const fruits = ['apple', 'banana', 'cherry'];

  return (
    <ul>
      {fruits.map(fruit => <li key={fruit}>{fruit}</li>)}
    </ul>
  );
}

export default FruitList;

Two key points:

  1. fruits.map(...) produces an array of JSX elements
  2. When React gets an array of JSX inside JSX, it renders those elements in order

You can put an array straight into JSX. There is one promise tied to that: each element must be given a prop called key.

Why is key necessary? #

key acts as the unique ID for identifying each item that React uses. When the list changes (additions / removals / reordering), React needs to be able to distinguish each item to figure out efficiently what changed.

Without key, React has a harder time deciding whether to draw each element from scratch or reuse an existing one. The result is degraded performance, weird flickers in some cases, or subtle bugs like an input field’s focus jumping to a wrong place.

If you omit key, React prints a warning in the console.

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

What makes a good key? #

A good key satisfies these conditions.

  • Unique — it should not collide with sibling items. It does not need to be unique worldwide; uniqueness within the same list is enough.
  • Stable — the same item must keep the same key across re-renders.

The most natural candidate is the unique ID the data already has. A primary key in the database, the id field returned by the server, and so on.

src/PostList.jsx
function PostList({ posts }) {
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          {post.title}
        </li>
      ))}
    </ul>
  );
}

For simple data without an ID (an array of strings, say), as long as the values are guaranteed to be unique, you can use the value itself as the key.

when values are unique
{fruits.map(fruit => <li key={fruit}>{fruit}</li>)}

But if an array could contain “apple” twice, you would get duplicate keys and a warning. In that case it is safer to assign an ID and treat the items as objects.

Can’t I just use the index? #

map can take an index as its second argument, so you might wonder, “Can’t I just use the index?”

anti-pattern
{fruits.map((fruit, index) => <li key={index}>{fruit}</li>)}

This works but is explicitly discouraged in the official React docs. If the list order can change, or items can be inserted / removed in the middle, this causes bugs.

An example where index key breaks #

Imagine the following.

bad — index key + input field
function TodoList() {
  const [todos, setTodos] = useState([
    { text: 'study React' },
    { text: 'exercise' },
    { text: 'read a book' },
  ]);

  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>
          {todo.text} <input type="text" placeholder="memo" />
        </li>
      ))}
    </ul>
  );
}

Each item has a memo input next to it, and let us say the user typed “7 PM” next to “exercise.” Then a new todo is inserted at the very top. What happens?

  • “study React,” which was at index 0, is now at index 1
  • “exercise,” which was at index 1, is now at index 2
  • The new item is at index 0

React reads the keys and concludes, “Item 0 is unchanged.” But the actual data is different. As a result, the “7 PM” the user typed next to “exercise” stays right where it was, now lined up next to a completely different item — a strange behavior.

The fix is to assign each item a real unique ID.

fixed code
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 'a1', text: 'study React' },
    { id: 'a2', text: 'exercise' },
    { id: 'a3', text: 'read a book' },
  ]);

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          {todo.text} <input type="text" placeholder="memo" />
        </li>
      ))}
    </ul>
  );
}

Now even when items are added or reordered, the input contents follow each item correctly.

When index key is safe #

When the list is static (no add / remove / reorder) and used only for display, using the index as key does not cause real problems. Even so, build the habit of using a unique ID when one exists. Lists that started out static often become dynamic later.

Tip
When you deal with data without an ID, attach an ID at creation time. crypto.randomUUID() in the browser generates a unique ID string. Or simply use an increasing number (Date.now() and the like).

Splitting into a component #

When the contents of <li> get long, splitting them into a separate component is the norm. Note: key must go on the top-level element returned by map.

src/TodoItem.jsx
function TodoItem({ todo }) {
  return (
    <li>
      <strong>{todo.text}</strong>. {todo.completed ? 'done' : 'in progress'}
    </li>
  );
}

export default TodoItem;
src/TodoList.jsx
import TodoItem from './TodoItem';

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

export default TodoList;

Do not attach key to the <li> inside TodoItem — attach it to <TodoItem> itself, the element map returns. Not somewhere inside the child component, but the very element that gets built as part of the list (the one returned by the map callback).

Combining with filter #

JavaScript array methods compose freely. To show only the incomplete todos, filter first and then chain map.

src/TodoList.jsx
function TodoList({ todos }) {
  return (
    <ul>
      {todos
        .filter(todo => !todo.completed)
        .map(todo => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
    </ul>
  );
}

Sorting works the same way with sort (or, more safely, [...todos].sort(...)).

Note
sort modifies the original array directly. Mutating an array received via props violates the “props are read-only” rule from Chapter 4, and mutating a state array violates the “no direct modification” rule from Chapter 5. Whenever you need to sort, always make a copy with [...todos].sort(...).

Handling empty arrays #

When the data is empty and you want to show a “nothing here” message, combine with the conditional rendering from Chapter 7.

src/TodoList.jsx
function TodoList({ todos }) {
  if (todos.length === 0) {
    return <p>No todos.</p>;
  }

  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

An empty <ul> is semantically awkward, so handling it via early return reads more naturally.

Try it yourself #

Let us evolve the MessageForm from Chapter 6 into a real message list. It uses everything we have covered up to here.

Change src/MessageForm.jsx to:

src/MessageForm.jsx
import { useState } from 'react';

function MessageForm() {
  const [name, setName] = useState('');
  const [message, setMessage] = useState('');
  const [messages, setMessages] = useState([]);

  const isValid = name.length > 0 && message.length > 0;

  function handleSubmit(e) {
    e.preventDefault();
    if (!isValid) return;
    const newMessage = {
      id: crypto.randomUUID(),
      name,
      message,
      createdAt: new Date().toLocaleTimeString(),
    };
    setMessages(prev => [newMessage, ...prev]);
    setName('');
    setMessage('');
  }

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder="Name"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
        <input
          type="text"
          placeholder="Message"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          style={{ marginLeft: '8px' }}
        />
        <button type="submit" disabled={!isValid} style={{ marginLeft: '8px' }}>
          Add
        </button>
      </form>

      <div style={{ marginTop: '16px' }}>
        {messages.length === 0 ? (
          <p style={{ color: '#888' }}>No messages yet.</p>
        ) : (
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {messages.map(item => (
              <li
                key={item.id}
                style={{ borderBottom: '1px solid #eee', padding: '8px 0' }}
              >
                <strong>{item.name}</strong>
                <span style={{ color: '#888', marginLeft: '8px', fontSize: '12px' }}>
                  {item.createdAt}
                </span>
                <p style={{ margin: '4px 0 0 0' }}>{item.message}</p>
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
}

export default MessageForm;

Try adding several messages. Each one stacks on top, and the unique ID built with crypto.randomUUID() is used as key. When the array is empty, the placeholder message shows; when there are messages, the list draws.

Everything we have learned so far is mixed into one screen: props (passing data to a child element), state (useState), event handling (onSubmit, onChange), conditional rendering (messages.length === 0 ? ... : ...), and the list rendering covered in this chapter (map + key). Short code, but it shows almost the entire core of React in one example.

Exercises #

  1. Add a “Delete” button next to each message item in MessageForm above. Clicking it should remove that message from the list. Pattern: setMessages(prev => prev.filter(m => m.id !== item.id)).
  2. Meet the index key trap firsthand. Temporarily change key={item.id} to key={index} (the second argument of the map callback), and add a per-item memo input <input type="text" placeholder="memo" />. After typing memos in two or three items, add a new message — you will see the memos follow the wrong items. Then switch back to key={item.id} and confirm the behavior disappears.
  3. Combine filter + map. Add a search input to MessageForm so that only items whose message body contains the typed term remain visible. Pattern: messages.filter(m => m.message.includes(search)).map(...). The search input itself is a controlled input (covered in Chapter 9).

In one line: For arrays, use map to build an array of JSX and embed it inside JSX. Each element needs a unique and stable key. Use the data’s ID when possible; index keys are an anti-pattern. Attach key to the top-level element the map callback returns. Combine freely with filter, sorting, and conditional rendering.

Next chapter #

This chapter is the first wrap-up of Part 1. You should be able to build small interactive components like counters, toggles, and message forms on your own. In the final chapter of Part 1, Chapter 9: Handling forms, we cover the canonical pattern for forms that appear in nearly every app — controlled components. This becomes the foundation for Chapter 19 (Typing events and forms) and Chapter 27 (Server Actions).

X