Module: React and Ecosystem
React and Ecosystem·092·9 MIN READ

092: Lists + Keys

TOPICS COVERED: Lists + Keys

Learning objective

Outcomes

You will transform arrays into JSX with map, filter data before rendering, choose stable keys, and render useful empty results.

I can explain that keys represent identity, not display position, and build a dynamic task list without index or random keys.

Prerequisites

Complete 091. You should know map, filter, props, and children, and be able to identify the element directly returned by a list mapping.

Retrieval practice

  1. Why are props read-only?
  2. What value does a missing boolean prop have?
  3. Why is key unavailable inside child props?

Content to cover

map in JSX; keys; stable identity; conditional list rendering.

Terms and mental model

An array stores domain records. map returns one React node for each record. filter returns a new subset without mutating the original. The node directly returned from map needs a key.

Keys are like filenames, not line numbers. Deleting the first file does not rename every other file. Likewise, deleting the first task should not change every remaining task's identity.

Beginner complete example

jsx
const tasks = [
  { id: 'task-101', title: 'Learn map', completed: true },
  { id: 'task-102', title: 'Choose stable keys', completed: false },
  { id: 'task-103', title: 'Render an empty state', completed: false },
];

function TaskItem({ task }) {
  return (
    <li>
      <span>{task.completed ? 'Complete' : 'Open'}</span>{' '}
      {task.title}
    </li>
  );
}

function TaskList({ tasks }) {
  if (tasks.length === 0) {
    return <p>No tasks match this view.</p>;
  }

  return (
    <ul>
      {tasks.map((task) => (
        <TaskItem key={task.id} task={task} />
      ))}
    </ul>
  );
}

export default function App() {
  const openTasks = tasks.filter((task) => !task.completed);
  return (
    <main>
      <h1>Open tasks</h1>
      <TaskList tasks={openTasks} />
    </main>
  );
}

The key belongs on <TaskItem> because that is the element directly produced in this array. Putting key on the li inside TaskItem is too late: React must identify the TaskItem siblings before calling them.

Choosing keys

Use IDs from a database or API. For locally created durable records, assign an ID when creating the record, for example crypto.randomUUID(). Do not create the UUID while rendering.

js
const newTask = {
  id: crypto.randomUUID(),
  title: 'New task',
  completed: false,
};

Avoid these:

jsx
tasks.map((task, index) => <TaskItem key={index} task={task} />)
tasks.map((task) => <TaskItem key={Math.random()} task={task} />)

Index keys can be acceptable for a truly static list that never reorders, inserts, deletes, or holds item state, such as fixed poem lines. Dynamic tasks fail those conditions. Random keys force fresh identity each render, recreating DOM, losing focus/input state, and doing extra work.

Keys are not globally unique. A task can use its ID in an open list and again in a completed list because each array has separate sibling scope. Duplicate keys in one array are a bug.

Intermediate: filtered groups

jsx
function TaskSection({ heading, tasks }) {
  const headingId = `${heading.toLowerCase()}-heading`;
  return (
    <section aria-labelledby={headingId}>
      <h2 id={headingId}>{heading}</h2>
      {tasks.length === 0 ? (
        <p>No {heading.toLowerCase()} tasks.</p>
      ) : (
        <ul>
          {tasks.map((task) => <TaskItem key={task.id} task={task} />)}
        </ul>
      )}
    </section>
  );
}

function Dashboard({ tasks }) {
  const open = tasks.filter((task) => !task.completed);
  const complete = tasks.filter((task) => task.completed);
  return (
    <>
      <TaskSection heading="Open" tasks={open} />
      <TaskSection heading="Complete" tasks={complete} />
    </>
  );
}

These subsets are derived values, not separate state. Filtering during render is appropriate for ordinary arrays. filter and map return new arrays and do not mutate tasks. In contrast, sort mutates, so copy first: const sorted = [...tasks].sort(compareTasks).

An arrow callback with braces needs return:

jsx
tasks.map((task) => {
  return <TaskItem key={task.id} task={task} />;
});

Without braces, the expression is implicitly returned.

Optional advanced: identity and state

Imagine each TaskItem later contains an edit input with local draft state. With index keys, removing task zero causes old item one's component state to be matched to new position zero. A draft can appear beside the wrong task. Stable IDs keep component state, focus, and DOM association with the domain entity.

When each record must return multiple sibling nodes without a wrapper, keyed Fragment shorthand cannot accept a key. Use:

jsx
import { Fragment } from 'react';

tasks.map((task) => (
  <Fragment key={task.id}>
    <h3>{task.title}</h3>
    <p>{task.completed ? 'Complete' : 'Open'}</p>
  </Fragment>
));

Do not use keys to silence warnings. A key encodes domain identity. Deliberately changing a component's key resets its state; that is useful only when reset is the intended behavior.

Mistakes and debugging

  • Missing key: inspect the element immediately returned by map.
  • Index key in an editable list: deleting/reordering can move local state.
  • Random or render-time UUID key: every render remounts every item.
  • Duplicate domain ID: correct the data source rather than combining with index.
  • Mutating with sort, reverse, or splice: copy or use non-mutating methods.
  • Forgetting return in a block-bodied map callback: array contains undefined.
  • Rendering an object directly: render fields such as task.title.
  • Using a key as an ordinary prop: pass id separately.

Reproduce identity bugs by typing into an item, then deleting or sorting another item. React Developer Tools can show remounts. Console key warnings usually identify the surrounding component, not necessarily the exact nested map, so inspect every array of JSX.

Accessibility and performance

Use ul/ol and li for lists; do not replace semantics with repeated divs. An empty ul communicates little, so render a plain-language empty message. If results change after a filter interaction, keep keyboard focus on the filter control and consider a restrained status message such as “3 tasks shown.” Do not put every list in a live region, which can become noisy.

Stable keys improve correctness and avoid unnecessary DOM replacement. Filtering and mapping modest arrays during render is normal. Do not add useMemo preemptively. For very large measured lists, consider pagination or windowing, but preserve keyboard navigation and announce result context.

Practice

Render a dynamic product/task list.

Tiered exercises

Core: Render task records with map, a TaskItem, and stable ID keys. Show an empty message when given [].

Stretch: Render separate open and complete sections from one array. Sort a copied array by title without mutating props.

Challenge: Add locally created records with crypto.randomUUID() at creation time, then verify that deleting and reordering preserve identity.

jsx
const tasks = [
  { id: 'a', title: 'Map records', completed: false },
  { id: 'b', title: 'Keep identity stable', completed: true },
];

function TaskItem({ task }) {
  return <li>{task.completed ? <s>{task.title}</s> : task.title}</li>;
}

function Section({ title, tasks }) {
  const sorted = [...tasks].sort((a, b) => a.title.localeCompare(b.title));
  return (
    <section>
      <h2>{title}</h2>
      {sorted.length === 0 ? <p>No tasks in this section.</p> : (
        <ul>{sorted.map((task) => <TaskItem key={task.id} task={task} />)}</ul>
      )}
    </section>
  );
}

export default function App() {
  return (
    <main>
      <h1>Task Manager</h1>
      <Section title="Open" tasks={tasks.filter((task) => !task.completed)} />
      <Section title="Complete" tasks={tasks.filter((task) => task.completed)} />
    </main>
  );
}

function createTask(title) {
  return { id: crypto.randomUUID(), title, completed: false };
}

createTask must run in an add event later, not inside map. IDs a and b remain attached when filtering or sorting changes positions.

Exit questions

  1. What problem does this concept solve?
  2. What is one common mistake?
  3. Can you explain the code without reading it line by line?

Recap

Use array operations to derive JSX, place a stable key on the node produced by map, and source that key from persistent record identity. Index and random keys are unsafe for dynamic tasks. Render explicit empty states and preserve semantic list markup.

Official references

Interview questions

  1. What does a key identify, and where must it be placed?
  2. Demonstrate the index-key bug using a stateful row and insertion at the beginning.
  3. When is an index key defensible?

Strong answer: A key identifies a sibling entity across renders. Use the durable task ID on the node returned by map; an index is defensible only for a truly static, non-reordered list with no item-local state.

Keys and state preservation

Keys are not only an optimization hint; they define identity among siblings. Index keys are safe only when a list is static and never reordered, inserted into, or filtered in a way that changes identity. Use a domain identifier for tasks and products.

Create a stateful Row component, insert an item at the beginning, reorder the list, and filter it. Record which state stays with the item under stable keys and which state moves with position under index keys.

Interview drill: identity under reconciliation

jsx
function StatefulRow({ item }) {
  const [note, setNote] = useState('');
  return <li><input aria-label={`note ${item.id}`} value={note} onChange={(e) => setNote(e.target.value)} /> {item.title}</li>;
}
const stable = items.map((item) => <StatefulRow key={item.id} item={item} />);
const positional = items.map((item, index) => <StatefulRow key={index} item={item} />);

Type keep me in item A, then insert item X at the beginning. Stable keys keep the note on A; index keys move it to the old position's new item. This is a correctness bug. Ask why key is unavailable in child props, what a changed key does to local state, and when an index key is defensible.


2026 depth expansion: key identity is state identity

Keys are not only a warning-suppression mechanism. They participate in React's answer to:

Is this the same component as before?

Consider an editable row. With a stable task ID:

jsx
{tasks.map((task) => (
  <EditableTask key={task.id} task={task} />
))}

React can preserve each row's local draft while the list changes position.

With the array index:

jsx
{tasks.map((task, index) => (
  <EditableTask key={index} task={task} />
))}

deleting the first item can cause the next item's component state to be reused in the wrong row.

A key can also deliberately reset state:

jsx
<ProfileEditor key={user.id} user={user} />

When user.id changes, React treats it as a different identity and mounts a fresh editor.

Use this intentionally; do not “fix” unwanted state by randomly generating keys on every render.


Deep dive: reconciliation inside dynamic collections

Keys are used among siblings, not globally.

These can both use "42" safely:

jsx
<ul>
  {tasks.map((task) => (
    <TaskRow key={task.id} task={task} />
  ))}
</ul>

<select>
  {users.map((user) => (
    <option key={user.id} value={user.id}>
      {user.name}
    </option>
  ))}
</select>

The key only needs to be unique among siblings in that particular list.

Why index keys fail under reordering

Start:

text
index 0 → Task A → editor state "Draft A"
index 1 → Task B → editor state "Draft B"

Delete Task A.

With index keys:

text
index 0 → Task B

React sees the component at key 0 and may preserve the state that previously belonged to Task A.

Now Task B can inherit the wrong local state.

Stable record IDs preserve the mapping:

text
key A → removed
key B → Task B still Task B

When index keys can be acceptable

Index keys can be acceptable for a truly static list where:

  • items are never reordered;
  • never inserted/removed;
  • contain no meaningful local component state;
  • no stable domain key exists.

Even then, a stable semantic ID is generally clearer.

Do not manufacture an ID during render:

jsx
key={crypto.randomUUID()}

That guarantees remounting.

If incoming data lacks IDs, normalize it when data enters your system, not during every render.

Key changes deliberately reset state

This is powerful:

jsx
<TaskEditor key={task.id} task={task} />

Changing selected task resets the editor draft because it is a new identity.

But do not use key reset to hide an ownership bug.

Ask first:

  • should this state belong to the task identity?
  • should it be preserved across selection?
  • should the parent own the draft?

Nested lists

Keys belong at the mapping boundary:

jsx
groups.map((group) => (
  <section key={group.id}>
    <h2>{group.name}</h2>

    <ul>
      {group.tasks.map((task) => (
        <TaskRow key={task.id} task={task} />
      ))}
    </ul>
  </section>
));

The inner task key only needs uniqueness within its inner sibling list.

Keys are not passed as normal props

This:

jsx
<TaskRow key={task.id} task={task} />

does not make key available inside TaskRow.

If the component needs the ID:

jsx
<TaskRow
  key={task.id}
  taskId={task.id}
  task={task}
/>

key is React identity metadata.

Filtering and key stability

Correct:

jsx
tasks
  .filter((task) => task.completed)
  .map((task) => (
    <TaskRow key={task.id} task={task} />
  ));

The task retains its ID even though its position changes.

Incorrect:

jsx
filteredTasks.map((task, index) => (
  <TaskRow key={index} task={task} />
));

Changing the filter can cause identity reuse for different records.

Sorting

Do not mutate state/props while sorting:

jsx
tasks.sort(...)

Use:

jsx
const sorted = [...tasks].sort(...);

or:

jsx
const sorted = tasks.toSorted(...);

Then map stable IDs.

Worked identity bug

jsx
function EditableList({ people }) {
  return (
    <ul>
      {people.map((person, index) => (
        <PersonRow key={index} person={person} />
      ))}
    </ul>
  );
}

function PersonRow({ person }) {
  const [note, setNote] = useState('');

  return (
    <li>
      <strong>{person.name}</strong>
      <input
        value={note}
        onChange={(e) => setNote(e.target.value)}
      />
    </li>
  );
}

Test:

  1. type a note in the second row;
  2. sort people alphabetically;
  3. inspect which person displays the note.

Then change key to person.id and repeat.

This exercise makes reconciliation concrete.

Lists and accessibility

A visible group of related items should often preserve semantic list markup:

jsx
<ul>
  ...
</ul>

Do not flatten every list into <div> just because CSS Grid/Flex can style it.

Navigation should commonly use:

jsx
<nav>
  <ul>
    ...
  </ul>
</nav>

Data tables should remain tables when the information is tabular.

React list rendering does not change semantic HTML principles.

Large-list architecture

Keys solve identity, not scalability.

If 30,000 rows are slow:

  • stable keys are still required;
  • reduce state placed above the list;
  • consider pagination;
  • consider virtualization;
  • profile render versus browser layout;
  • avoid giant DOM trees.

Virtualization is revisited in performance.

Exercises

  1. Reproduce index-key state leakage.
  2. Fix it with stable IDs.
  3. Use a key deliberately to reset an editor.
  4. Render nested groups with correct keys at both levels.
  5. Explain why crypto.randomUUID() inside render is harmful.
  6. Profile a list of 100, 1,000, and 10,000 keyed rows.

Mastery check

Explain precisely:

  • where a key must be unique;
  • how a key participates in identity;
  • why index keys break during insertion/reorder/filtering;
  • why random keys force remounts;
  • why stable keys do not automatically make huge lists fast.