FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·092·10 MIN READ

092: Lists + Keys

TOPICS COVERED: Lists + Keys

Learning objective

Outcomes

You will turn arrays into JSX with map, filter data before rendering, choose stable keys, and give users a useful result when a list is empty.

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

Prerequisites

Complete 091 first. You should already know map, filter, props, and children, and you should be able to identify the element that a list mapping returns directly.

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 holds records from your domain. Calling map produces one React node for each record, while filter creates a new subset without changing the original array. The node returned directly by map is the node that needs a key.

  • Key: A stable identifier that gives a list item identity across re-renders. — Source: React: Rendering lists
  • Stable: An identity that does not change between renders, allowing state and DOM nodes to be preserved. — Source: React: Preserving and resetting state
  • Sibling scope: Keys need to be unique among siblings, not across the entire application. — Source: React: Rendering lists
  • Reconciliation: React's process of comparing the new element tree with the previous one to minimize DOM work. — Source: React: Understanding your UI as a tree
  • Conditional list: Output selected from list data, such as empty or filtered UI (course term).

Keys are closer to filenames than line numbers. Removing the first file does not rename every remaining file. In the same way, removing the first task should not change the identity of every task that follows it.

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 is placed on <TaskItem> because that is the element this array produces directly. Putting key on the li inside TaskItem is too late: React needs to identify the TaskItem siblings before it calls those components.

Choosing keys

Prefer IDs supplied by a database or API. For durable records created locally, assign an ID when the record is created, for example with crypto.randomUUID(). Do not generate that UUID while rendering.

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

Avoid these approaches:

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

An index key can be acceptable for a genuinely static list that never reorders, inserts, deletes, or contains item state, such as fixed lines of a poem. Dynamic tasks do not meet those conditions. Random keys create a fresh identity on every render, which remounts items, loses focus and input state, and makes React do unnecessary work.

Keys do not have to be globally unique. The same task ID can appear in an open list and a completed list because those arrays have separate sibling scopes. Two items with the same key in one array, however, indicate a data or rendering 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} />
    </>
  );
}

open and complete are derived values, not second copies of state. Filtering during render is a good fit for ordinary arrays. Both filter and map return new arrays and leave tasks unchanged. sort, by contrast, mutates its array, so copy before sorting: const sorted = [...tasks].sort(compareTasks).

An arrow callback with braces does not implicitly return anything, so it needs an explicit return:

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

When the braces are omitted, the expression is returned implicitly.

Optional advanced: identity and state

Suppose each TaskItem later contains an edit input with local draft state. With index keys, removing task zero makes the component at the old position one match the new position zero. The draft can then appear beside the wrong task. Stable IDs keep the component state, focus, and DOM association attached to the domain entity.

There is one related detail when a record needs to return several sibling nodes without a wrapper. Fragment shorthand cannot receive a key, so use the long form:

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 merely to silence a warning. A key expresses domain identity. Deliberately changing a component's key resets its state, which is useful only when resetting state is the intended behavior.

Mistakes and debugging

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

To reproduce an identity bug, type into one item and then delete or sort a different item. React Developer Tools can help you see remounts. Console warnings about keys often point to the surrounding component rather than the exact nested mapping, so inspect every array that produces JSX.

Accessibility and performance

Use ul or ol with li for lists. Repeated divs may be easy to style, but they do not provide the same semantics. An empty ul tells the user very little, so render a plain-language empty message instead. If a filter interaction changes the results, 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; frequent announcements quickly become noisy.

Stable keys improve correctness and prevent unnecessary DOM replacement. Filtering and mapping a modest array during render is normal, so do not add useMemo preemptively. For very large, measured lists, consider pagination or windowing while preserving keyboard navigation and announcing enough result context.

Practice

Render a dynamic product or task list.

Tiered exercises

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

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 belongs in a later add event, not inside map. The IDs a and b remain attached to their records even when filtering or sorting changes their 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, put a stable key on the node produced by map, and take that key from the persistent identity of the record. Index and random keys are unsafe for dynamic tasks. Render an explicit empty state, and keep semantic list markup in the output.

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. Put 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 more than an optimization hint: among siblings, they define identity. Index keys are safe only when a list is static and is never reordered, inserted into, or filtered in a way that changes identity. For tasks and products, use a domain identifier.

Create a stateful Row component, insert an item at the beginning, reorder the list, and filter it. Record which state remains with the item when stable keys are used, and which state follows the position when index keys are used.

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 into item A, then insert item X at the beginning. Stable keys keep the note on A; index keys move it to the new item occupying the old position. That is a correctness bug, not merely a performance detail. Ask why key is unavailable in child props, what changing a key does to local state, and when an index key is defensible.


2026 depth expansion: key identity is state identity

Keys do not exist just to suppress warnings. They contribute to React's answer to this question:

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 even as the row 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 for the wrong row.

A key can also be used deliberately to reset state:

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

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

Use this behavior intentionally. Do not “fix” unwanted state by generating random keys on every render.


Deep dive: reconciliation inside dynamic collections

Keys are scoped to siblings; they are not global identifiers.

These two lists 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>

Each key only needs to be unique among the siblings in its own list.

Why index keys fail under reordering

Start with this relationship between positions, records, and editor state:

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

Delete Task A.

With index keys, the remaining list looks like this:

text
index 0 → Task B

React sees the component with key 0 and may preserve the state that previously belonged to Task A. Task B can therefore inherit the wrong local state.

Stable record IDs preserve the actual mapping:

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

When index keys can be acceptable

An index key can be acceptable for a truly static list when all of these conditions hold:

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

Even in that narrow case, a stable semantic ID is usually clearer.

Do not manufacture an ID during render:

jsx
key={crypto.randomUUID()}

That guarantees remounting.

If incoming data has no IDs, normalize it when the data enters your system, rather than inventing a new identity during every render.

Key changes deliberately reset state

This is a useful, intentional reset:

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

Changing the selected task resets the editor draft because React sees a new identity.

Do not use a key reset to conceal 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

Put keys at the boundary where each array is mapped:

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 to be unique within that inner sibling list.

Keys are not passed as normal props

This usage:

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

does not make key available inside TaskRow.

If the component needs the ID as data, pass it explicitly:

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

key is React's identity metadata, not an ordinary prop.

Filtering and key stability

This preserves the record's identity while selecting a subset:

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

The task keeps its ID even though its position in the rendered array changes.

This uses position as identity and is unsafe:

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

Changing the filter can then reuse one identity for a different record.

Sorting

Do not mutate state or props while sorting:

jsx
tasks.sort(...)

Make a copy first:

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

Or use:

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

After sorting, map the records with 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 it in this order:

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

Then change the key to person.id and repeat the test.

This small exercise turns reconciliation from an abstract rule into an observable behavior.

Lists and accessibility

A visible group of related items should usually retain semantic list markup:

jsx
<ul>
  ...
</ul>

Do not flatten every list into <div> elements just because CSS Grid or Flexbox can style them.

Navigation commonly has this structure:

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

When information is tabular, keep it in a data table rather than replacing the table with unrelated elements.

React list rendering does not change the principles of semantic HTML.

Large-list architecture

Keys solve identity, not scalability.

If 30,000 rows are slow, stable keys are still necessary, but they are not the whole solution:

  • stable keys are still required;
  • reduce state placed above the list;
  • consider pagination;
  • consider virtualization;
  • profile render time separately from browser layout;
  • avoid creating a giant DOM tree.

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, reordering, and filtering;
  • why random keys force remounts;
  • why stable keys do not automatically make huge lists fast.
Reader page: /react/lesson/092/lists-keys