Lesson 8 of 12

React Lists and Keys

Most interfaces show lists: products, messages, search results, menu items. You rarely write those by hand. Instead, the data lives in an array, and React turns each item into a piece of JSX. This lesson on React lists and keys shows how to do that with the array methods you met in JavaScript Arrays, and why React asks you to give every item a key.

React lists and keys: rendering an array with map()

map() creates a new array by running a function on every item. If that function returns JSX, you get an array of elements, and React can render an array placed inside curly braces. That is all list rendering is.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A list from an array of objects</title>
  <script src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script>
  <style>
    body { font-family: system-ui, sans-serif; padding: 16px; line-height: 1.5; }
    li { margin-bottom: 4px; }
    .km { color: #666; font-size: 14px; }
  </style>
</head>
<body>
  <div id="root"></div>

  <script type="text/babel" data-type="module">
import React from "https://esm.sh/[email protected]";
import { createRoot } from "https://esm.sh/[email protected]/client";

const trips = [
  { id: "t1", place: "Auroville", km: 12 },
  { id: "t2", place: "Mahabalipuram", km: 95 },
  { id: "t3", place: "Gingee Fort", km: 68 },
  { id: "t4", place: "Chidambaram", km: 66 }
];

function App() {
  return (
    <div>
      <h2>Day trips from Pondicherry</h2>
      <ul>
        {trips.map(trip => (
          <li key={trip.id}>
            {trip.place} <span className="km">({trip.km} km)</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<App />);
  </script>
</body>
</html>

You can also build the array first and place it later: const items = trips.map(…); and then <ul>{items}</ul>. Both styles are fine; keeping map() inline is more common for short lists.

Filtering before rendering

To show only some items, chain filter() before map(). Combined with state, this gives you a live filter. Note that the filtered list is calculated during rendering; it is not stored in a second piece of state. That keeps the original array as the single source of truth.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A filterable list</title>
  <script src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script>
  <style>
    body { font-family: system-ui, sans-serif; padding: 16px; line-height: 1.5; }
    label { margin-right: 12px; }
    li { margin-bottom: 4px; }
  </style>
</head>
<body>
  <div id="root"></div>

  <script type="text/babel" data-type="module">
import React, { useState } from "https://esm.sh/[email protected]";
import { createRoot } from "https://esm.sh/[email protected]/client";

const dishes = [
  { id: 1, name: "Idiyappam", veg: true },
  { id: 2, name: "Fish curry", veg: false },
  { id: 3, name: "Avial", veg: true },
  { id: 4, name: "Mutton biryani", veg: false },
  { id: 5, name: "Bisi bele bath", veg: true }
];

function App() {
  const [vegOnly, setVegOnly] = useState(false);
  const [search, setSearch] = useState("");

  const visible = dishes
    .filter(d => !vegOnly || d.veg)
    .filter(d => d.name.toLowerCase().includes(search.toLowerCase()));

  return (
    <div>
      <label>
        <input type="checkbox" checked={vegOnly} onChange={e => setVegOnly(e.target.checked)} /> Veg only
      </label>
      <label>
        Search <input value={search} onChange={e => setSearch(e.target.value)} />
      </label>
      <p>{visible.length} of {dishes.length} dishes</p>
      <ul>
        {visible.map(d => <li key={d.id}>{d.name}{d.veg ? " (veg)" : ""}</li>)}
      </ul>
      {visible.length === 0 && <p>No dishes match.</p>}
    </div>
  );
}

createRoot(document.getElementById("root")).render(<App />);
  </script>
</body>
</html>

Sorting without mutating

Be careful with sort() and reverse(): they change the original array in place, which breaks the rule that state and props must not be mutated. Copy the array first with [...items].sort(…), or use the newer toSorted() and toReversed() methods, which return a new array and leave the original alone. Like filtering, sorting is usually done during rendering from the original data.

Why React needs keys

A key is a special attribute that gives each list item a stable identity. When the list changes – an item is added, removed or moved – React compares the old and new keys to work out which item is which. With good keys, React keeps each item’s DOM and state attached to the right data, and updates only what changed. Think of keys as file names in a folder: you can sort the folder any way you like and still know which file is which.

If you forget the key, React shows a warning in the console: “Each child in a list should have a unique key prop”.

Rules for keys

  • Unique among siblings. Two items in the same list cannot share a key. Items in different lists can.
  • Stable. The same item must get the same key on every render. Never use Math.random() or a new id generated during rendering.
  • From the data. Use ids from your database or API. For items created in the browser, create the id once when the item is added, for example with crypto.randomUUID() or a counter.
  • On the outer element returned by map() – the <li> or the component, not something inside it.

The trouble with index keys

The second argument of map() is the index, and it is tempting to write key={index}. It removes the warning, and it is acceptable for a list that never changes order and never has items removed. But if you delete or reorder items, the indexes shift, and React matches state to the wrong item. In the demo below, type something in the note box of the first person, then remove that person. With index keys, the note jumps to the next person.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Index keys versus id keys</title>
  <script src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script>
  <style>
    body { font-family: system-ui, sans-serif; padding: 16px; line-height: 1.5; }
    .cols { display: flex; gap: 24px; flex-wrap: wrap; }
    .row { margin: 4px 0; }
    input { width: 110px; }
  </style>
</head>
<body>
  <div id="root"></div>

  <script type="text/babel" data-type="module">
import React, { useState } from "https://esm.sh/[email protected]";
import { createRoot } from "https://esm.sh/[email protected]/client";

const start = [
  { id: "a", name: "Lakshmi" },
  { id: "b", name: "Farhan" },
  { id: "c", name: "Joseph" }
];

function Row({ name, onRemove }) {
  return (
    <div className="row">
      {name} <input placeholder="note" aria-label={"Note for " + name} />{" "}
      <button type="button" onClick={onRemove}>Remove</button>
    </div>
  );
}

function PeopleList({ useIndex }) {
  const [people, setPeople] = useState(start);

  function remove(id) {
    setPeople(people.filter(p => p.id !== id));
  }

  return (
    <div>
      <h3>{useIndex ? "key={index} (buggy)" : "key={person.id} (correct)"}</h3>
      {people.map((p, index) => (
        <Row key={useIndex ? index : p.id} name={p.name} onRemove={() => remove(p.id)} />
      ))}
      <button type="button" onClick={() => setPeople(start)}>Reset</button>
    </div>
  );
}

function App() {
  return (
    <div className="cols">
      <PeopleList useIndex={true} />
      <PeopleList useIndex={false} />
    </div>
  );
}

createRoot(document.getElementById("root")).render(<App />);
  </script>
</body>
</html>

Tip: Removing an item is done with filter(), which returns a new array – never with splice(), which changes the array in place. This follows the state rules from React State with useState.

Keys on fragments

Sometimes each item needs to produce several elements without a wrapper, such as a <dt> and <dd> pair. The short <></> syntax cannot take a key, so import Fragment and write <Fragment key={item.id}>…</Fragment>. Also remember that your component does not receive key as a prop; if it needs the id, pass it again, e.g. <Row key={p.id} id={p.id} />.

import { Fragment } from "react";

function Glossary({ terms }) {
  return (
    <dl>
      {terms.map(t => (
        <Fragment key={t.id}>
          <dt>{t.word}</dt>
          <dd>{t.meaning}</dd>
        </Fragment>
      ))}
    </dl>
  );
}

Further reading

Key takeaways

  • Render lists by mapping an array to JSX inside curly braces.
  • Chain filter() before map() and calculate filtered lists during rendering.
  • Every item returned from map() needs a key that is unique among siblings and stable.
  • Use ids from your data; avoid Math.random() and be careful with index keys.
  • Remove items with filter() to create a new array.
  • Use <Fragment key={…}> when an item renders several elements without a wrapper.

Quick quiz

Check what you learned – pick an answer.

1. Which is the best key for a list of orders from an API?

2. Where does the key go in {users.map(u => <UserCard … />)}?

3. Why can key={index} cause bugs?

Practice exercise

Render a to-do list from an array of objects with id, text and done. Done tasks should appear with a line through them. Add a heading that shows how many tasks are still pending.

Show a hint

Use filter(t => !t.done).length for the count and a className or style for done items.

Show the solution
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Task list</title>
  <script src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script>
  <style>
    body { font-family: system-ui, sans-serif; padding: 16px; line-height: 1.5; }
    .done { text-decoration: line-through; color: #888; }
  </style>
</head>
<body>
  <div id="root"></div>

  <script type="text/babel" data-type="module">
import React from "https://esm.sh/[email protected]";
import { createRoot } from "https://esm.sh/[email protected]/client";

const tasks = [
  { id: 1, text: "Buy flowers for Pongal", done: true },
  { id: 2, text: "Draw the kolam", done: false },
  { id: 3, text: "Call Paati", done: false }
];

function App() {
  const pending = tasks.filter(t => !t.done).length;

  return (
    <div>
      <h3>{pending} tasks pending</h3>
      <ul>
        {tasks.map(t => (
          <li key={t.id} className={t.done ? "done" : ""}>{t.text}</li>
        ))}
      </ul>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<App />);
  </script>
</body>
</html>

Progress is saved in this browser only.