Lists and keys

Published:

Say, we start with data list in the shape it usually has before it becomes UI.

const groceries = [
  { id: 'apples', name: 'Apples' },
  { id: 'bread', name: 'Bread' },
  { id: 'coffee', name: 'Coffee' },
];

function GroceryList() {
  return <ul>{groceries}</ul>;
}

root.render(<GroceryList />);

Waiting to run

Not run yet.

That fails before we have anything that looks like a list.

React was given an array in a child position, but the array contains plain objects. A plain object like { id: 'apples', name: 'Apples' } is data and React is expecting a React node.

if one child can be rendered, how do many similar children come from an array of data?

As the React error suggests, the array is a good idea. The items inside the array are the wrong shape. React can render child values such as strings, numbers, elements, null, and false. It cannot render arbitrary data objects as children. So the first constraint is:

data must become renderable nodes before it reaches the child position

Hard-coding loses the data #

We already know how to make one list item by hand:

function GroceryList() {
  return (
    <ul>
      <li>Apples</li>
      <li>Bread</li>
      <li>Coffee</li>
    </ul>
  );
}

root.render(<GroceryList />);

Waiting to run

Not run yet.

This renders the right DOM shape, but it throws the array away. The data still exists somewhere else, and the JSX now has a copied version of it.

If the array changes, this component does not follow it. We have to keep the data as the source:

how do we turn each data item into one React element?

Mapping data to elements #

JavaScript arrays already have an operation for transforming every item into another value. map() calls a function for each array item and returns a new array containing the results.[2] Let's try using it to turn each grocery object into one <li>:

const groceries = [
  { id: 'apples', name: 'Apples' },
  { id: 'bread', name: 'Bread' },
  { id: 'coffee', name: 'Coffee' },
];

function GroceryList() {
  const items = groceries.map((grocery) => (
    <li>{grocery.name}</li>
  ));

  return <ul>{items}</ul>;
}

root.render(<GroceryList />);

Waiting to run

Not run yet.

Now the list appears. The child position receives an array of React elements instead of an array of plain data objects.

The useful shape is:

data.map((item) => elementFor(item))

For this example:

groceries.map((grocery) => <li>{grocery.name}</li>)

Each object stays data. Its name field becomes text inside an element. The array of objects never reaches React as the final UI. The array of elements does. But the demo did not finish cleanly. It rendered and then React logged a warning:

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

We solved "how do many nodes come from data?" and immediately exposed a React-specific question:

when many sibling nodes come from an array, how does React know which child is which?

Adding keys #

The warning names a prop: key. So the smallest next attempt is to add that prop to each element produced by map(). The grocery objects already have an id field. That field is not visible UI, but it is different for each grocery.

const groceries = [
  { id: 'apples', name: 'Apples' },
  { id: 'bread', name: 'Bread' },
  { id: 'coffee', name: 'Coffee' },
];

function GroceryList() {
  const items = groceries.map((grocery) => (
    <li key={grocery.id}>{grocery.name}</li>
  ));

  return <ul>{items}</ul>;
}

root.render(<GroceryList />);

Waiting to run

Not run yet.

The warning is gone.[1] That tells us what React was missing in the previous render: every sibling element produced from the array needed a key.

This line has two different uses of the same data object:

<li key={grocery.id}>{grocery.name}</li>

grocery.name is content for the user. grocery.id is the value that made the warning disappear.

So the next rule is still narrow:

when an array creates sibling elements, each sibling needs a key

That answers the warning, but not the whole identity problem yet. We still need to learn what kind of key behaves correctly when the list changes. One boundary is already visible: key is not ordinary visible content. It is not rendered as a DOM attribute.

How about we move the <li> into GroceryItem and put the key there:

const groceries = [
  { id: 'apples', name: 'Apples' },
  { id: 'bread', name: 'Bread' },
  { id: 'coffee', name: 'Coffee' },
];

function GroceryItem({ grocery }) {
  return <li key={grocery.id}>{grocery.name}</li>;
}

function GroceryList() {
  return (
    <ul>
      {groceries.map((grocery) => (
        <GroceryItem grocery={grocery} />
      ))}
    </ul>
  );
}

root.render(<GroceryList />);

Waiting to run

Not run yet.

The warning comes back. The sibling elements created by map() are not the <li> elements anymore. They are the <GroceryItem /> elements. So the key has to be on the element that the array expression creates:

const groceries = [
  { id: 'apples', name: 'Apples' },
  { id: 'bread', name: 'Bread' },
  { id: 'coffee', name: 'Coffee' },
];

function GroceryItem({ grocery }) {
  return <li>{grocery.name}</li>;
}

function GroceryList() {
  return (
    <ul>
      {groceries.map((grocery) => (
        <GroceryItem key={grocery.id} grocery={grocery} />
      ))}
    </ul>
  );
}

root.render(<GroceryList />);

Waiting to run

Not run yet.

The warning is gone again. GroceryItem receives grocery, but it does not receive a normal key prop. React consumes key before props reach the component.

The return pitfall #

Before we use keys for changing lists, there is one ordinary JavaScript trap in the map() callback. An arrow function with an expression body returns that expression:

groceries.map((grocery) => <li key={grocery.id}>{grocery.name}</li>)

But an arrow function with a block body does not return automatically:

const groceries = [
  { id: 'apples', name: 'Apples' },
  { id: 'bread', name: 'Bread' },
  { id: 'coffee', name: 'Coffee' },
];

function GroceryList() {
  return (
    <ul>
      {groceries.map((grocery) => {
        <li key={grocery.id}>{grocery.name}</li>;
      })}
    </ul>
  );
}

root.render(<GroceryList />);
console.log('list text:', mountNode.textContent);

Waiting to run

Not run yet.

The callback ran, but it returned undefined for each item. React received an array of undefined values, so nothing visible appeared.

Seems like like we have to return the element explicitly:

const groceries = [
  { id: 'apples', name: 'Apples' },
  { id: 'bread', name: 'Bread' },
  { id: 'coffee', name: 'Coffee' },
];

function GroceryList() {
  return (
    <ul>
      {groceries.map((grocery) => {
        return <li key={grocery.id}>{grocery.name}</li>;
      })}
    </ul>
  );
}

root.render(<GroceryList />);

Waiting to run

Not run yet.

Keys placement #

The warning only told us that each child needs a key. A tempting way to silence it is to use the array index:

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

That gives every sibling a key, so it satisfies the warning. But it creates a new question:

does the key identify the data item, or only the current position?

JSX creates element descriptions, and those descriptions contain a key. We can try comparing descriptions before and after reordering the same data:

const tasks = [
  { id: 'draft', label: 'Draft article' },
  { id: 'review', label: 'Review examples' },
  { id: 'publish', label: 'Publish notes' },
];

const reorderedTasks = [tasks[2], tasks[1], tasks[0]];

function describe(elements) {
  return elements.map((element) => {
    return element.key + ' -> ' + element.props.children;
  }).join(' | ');
}

const before = tasks.map((task, index) => (
  <li key={index}>{task.label}</li>
));

const after = reorderedTasks.map((task, index) => (
  <li key={index}>{task.label}</li>
));

console.log('before:', describe(before));
console.log('after:', describe(after));
root.render(<ul>{after}</ul>);

Waiting to run

Not run yet.

The same key points to different data:

before: 0 -> Draft article
after:  0 -> Publish notes

The warning was gone, but the key followed the position. Position 0 used to mean Draft article; after the reorder, position 0 means Publish notes.

So the next constraint is sharper:

a key should identify the data item, not the array position

Maybe the fix is to use the task id instead of the position:

const tasks = [
  { id: 'draft', label: 'Draft article' },
  { id: 'review', label: 'Review examples' },
  { id: 'publish', label: 'Publish notes' },
];

const reorderedTasks = [tasks[2], tasks[1], tasks[0]];

function describe(elements) {
  return elements.map((element) => {
    return element.key + ' -> ' + element.props.children;
  }).join(' | ');
}

const before = tasks.map((task) => (
  <li key={task.id}>{task.label}</li>
));

const after = reorderedTasks.map((task) => (
  <li key={task.id}>{task.label}</li>
));

console.log('before:', describe(before));
console.log('after:', describe(after));
root.render(<ul>{after}</ul>);

Waiting to run

Not run yet.

Now each key stays attached to the same task:

before: draft -> Draft article | review -> Review examples | publish -> Publish notes
after:  publish -> Publish notes | review -> Review examples | draft -> Draft article

The order changed, but the pairs did not: draft still points to Draft article, review still points to Review examples, and publish still points to Publish notes.[1] For lists that can reorder, insert, or delete items, a stable id from the data preserves that connection. The current array index does not.

Filtering before mapping #

Now that every rendered item has identity, we can add conditional list shape. Suppose only incomplete tasks should be shown. One version maps every item and returns null for the items we do not want:

const tasks = [
  { id: 'draft', label: 'Draft article', done: true },
  { id: 'review', label: 'Review examples', done: false },
  { id: 'publish', label: 'Publish notes', done: false },
];

function OpenTasks() {
  return (
    <ul>
      {tasks.map((task) => {
        if (task.done) {
          return null;
        }

        return <li key={task.id}>{task.label}</li>;
      })}
    </ul>
  );
}

root.render(<OpenTasks />);

Waiting to run

Not run yet.

That works because null means "no visible node" in a child position. But the callback is doing two jobs at once: deciding whether the task remains and turning the remaining task into an element. JavaScript arrays have a separate operation for keeping only some items. filter() returns a new array containing only the items that pass a test.[3]

So the same component can separate the two steps:

const tasks = [
  { id: 'draft', label: 'Draft article', done: true },
  { id: 'review', label: 'Review examples', done: false },
  { id: 'publish', label: 'Publish notes', done: false },
];

function OpenTasks() {
  const openTasks = tasks.filter((task) => !task.done);
  const items = openTasks.map((task) => (
    <li key={task.id}>{task.label}</li>
  ));

  return <ul>{items}</ul>;
}

root.render(<OpenTasks />);

Waiting to run

Not run yet.

Now each step has one job:

  1. filter() chooses which data items remain
  2. map() turns those data items into keyed React nodes
  3. JSX places that node array inside the <ul>

This connects lists back to conditions:

a condition can choose one child, and filter() can choose many data items before they become children

Lists belong to each render #

Like conditions / transformations happen during render. A component does not remember the old array of <li> elements and patch it by hand. It renders from the current inputs.

const allTasks = [
  { id: 'draft', label: 'Draft article', done: true },
  { id: 'review', label: 'Review examples', done: false },
  { id: 'publish', label: 'Publish notes', done: false },
];

function TaskFilter({ showDone }) {
  const visibleTasks = allTasks.filter((task) => {
    return showDone || !task.done;
  });

  return (
    <ul>
      {visibleTasks.map((task) => (
        <li key={task.id}>{task.label}</li>
      ))}
    </ul>
  );
}

root.render(<TaskFilter showDone={false} />);
setTimeout(() => {
  console.log('first render:', mountNode.textContent);
  root.render(<TaskFilter showDone={true} />);
  setTimeout(() => {
    console.log('second render:', mountNode.textContent);
  }, 0);
}, 20);

Waiting to run

Not run yet.

The first render receives showDone={false}, so the completed task is filtered out. The second render receives showDone={true}, so the same filter() expression includes every task.

The list follows the same render model as everything else so far:

  1. props provide the input for this render
  2. JavaScript computes arrays from that input
  3. map() produces keyed React nodes
  4. the component returns a React description
  5. React DOM updates the real DOM from that description

Filling the hole #

We started by putting an array of data objects into JSX, and that failed because objects are not renderable child nodes. The first fix was to transform data into elements with map(). That rendered the list, but it exposed the next warning: each sibling in the list needed a key.

The key examples narrowed that warning into a rule:

  • arrays can appear in child positions when their items are renderable nodes
  • map() turns each data item into one React node
  • each sibling created from an array needs a key
  • when list order can change, the key must stay attached to the data item rather than the current array position
  • object fields can become text, props, attributes, child elements, and keys
  • filter() can choose which data items remain before map() renders them
  • each render recomputes the list from that render's inputs

Final definition #

List rendering is the practice of transforming an array of data into an array of React nodes, usually with map(). A key is the value React uses to identify one sibling from the array across renders; for changing lists, that value should come from stable data identity.

Summary #

Lists and keys fill the hole after conditional rendering:

  • raw data objects are not renderable children
  • map() is the usual bridge from data items to elements
  • a mapped list can render and still be incomplete if its siblings have no keys
  • keys identify siblings for React
  • stable IDs from data preserve item identity when order can change
  • block-bodied arrow callbacks need an explicit return
  • filter() chooses which data items should be mapped
  • list output is recalculated during each render

Notes

  1. The live demos in this article use the React 19.2.5 and react-dom 19.2.5 development modules. Examples focus on author-facing list rendering and key behavior rather than React's internal reconciliation implementation.

References

  1. React: Rendering Lists (opens in a new tab) · Back
  2. MDN: `Array.prototype.map()` (opens in a new tab) · Back
  3. MDN: `Array.prototype.filter()` (opens in a new tab) · Back