Skip to content
wannacode

Lists and keys

In short: A list in React is made with map from an array to components, and key tells React which old row is which new one; a random number is a bad key, and so is the index once the list changes.

Problem

I removed the bread, and the milk became 3

The buyer has bread, milk and cheese in the cart. They type 3 for the bread and 2 for the milk, and then remove the bread. Try it: change the two numbers and press "Remove" next to the bread.

React

The bread is gone, but the milk is now 3 and the cheese is 2. There is no error in the console. The data are right: exactly the bread was removed. Only the fields are mixed up. The cause is one attribute on line 20 of App.jsx: what the code tells the library about every row of the list.

The idea

A list in a component

In the DOM module a list was made from an array with map and createElement. In a component the idea is the same, but every item of the array becomes part of the markup:

<ul>
{items.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
</li>
))}
</ul>

map returns an array of JSX elements, and React draws them one after another. Every element of the array has a key attribute: the key by which React recognizes it.

When the data change, the component returns a new list. The library does not delete the old one to draw it again. It compares the old and the new one and changes only the difference on the page. That is why the focus and what was typed into fields stay, instead of getting lost as with render() in the DOM module.

To compare, it has to know which old row matches which new row. That is what the key says.

The idea

Pairing by key

The library pairs the old and the new rows by key:

  • The same key is the same row. The element on the page stays, and the library changes in it only what differs in the data: the name, the price.
  • A key that is not in the new list is a removed row. Its element is removed from the page.
  • A new key is a new row. A new element is made.

The element that stays keeps everything of its own: what was typed into an <input>, the focus, an open drop-down. These are not in the data but in the element itself. That is why it matters that it stays with its product.

In the example the key is the index: the place in the array. After "Remove" the milk is at place 0. The old row with key 0 belonged to the bread. The library keeps it, changes its name to "Fresh milk" and leaves the field with 3.

See it

See the pairing

On the left is the page before "Remove", on the right the new list from the component. Watch which row goes.

// key = the place in the list
{items.map((p, index) => (
<li key={index}>
<ProductCard product={p} />
</li>
))}
// key = the id of the product
{items.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
</li>
))}
Step by step, in words
  1. Three rows in the cart, and the key is the place: 0, 1, 2. The buyer has typed 3, 2 and 1 into the fields. What was typed lives in the <input> element, not in the data.
  2. "Remove White bread". The data now hold two products, and the component returns a new list: key 0 is the milk, key 1 is the cheese.
  3. The library pairs the old and the new list by key: 0 with 0, 1 with 1. Key 2 is not in the new list, so the third row goes: the one of the cheese.
  4. The kept rows get the new data, and their headings now say milk and cheese. The fields are the old ones, with 3 and 2. The quantity of the bread is now on the milk.
  5. The same cart with key = id. The keys are 1, 2, 3 and belong to the product, not to the place.
  6. "Remove White bread" again. The new list has the keys 2 and 3.
  7. Key 2 pairs with key 2, and 3 with 3, even though they moved up. Key 1 is missing, so the row of the bread goes, together with its field.
  8. Every field stayed with its product: milk 2, cheese 1. The key tells the library who is who, not where it is.

With the index the last row goes, and the first two change their names. With the id the row of the bread goes, and the other two only move up with their fields. In both cases the data are the same. The difference is what the key says: the place or the product.

Try it

Fix the key

Fix the cart so that after "Remove" every number stays with its product. The check types 3 for the bread and 2 for the milk, removes the bread and looks at the fields of the milk and the cheese.

React

Try it

Fill in

What is the attribute by which the library recognizes a row?

{items.map((p) => (
  <li ={p.id}>
    <ProductCard product={p} />
  </li>
))}

The idea

A good key

A good key is stable and unique: the same for the same product on every render, and different for different products. The best one is an id from the data: it comes from the database and does not change.

  • The index is a bad key if the list changes: removing, adding at the start, sorting, filtering. It is fine if the list is read only and never changes its order.
  • Math.random() is always a bad key. On every render the key is new, so no old row has a pair. The library removes all rows and makes new ones. The fields lose what was typed, and the focus disappears.
  • Two equal keys confuse the pairing. Here the key is the name, and there are two sizes of "Yogurt". Press "Sort by price" and count the rows.
React

There are four products, and the rows become five: one yogurt is there twice.

React says so on the very first render, with an error in the console: "Encountered two children with the same key, Yogurt". If you forget the key, the error is "Each child in a list should have a unique "key" prop." Both messages come from React in development mode. On the real site the console is silent, and the bug on the page stays.

Try it

Find the bug

On every render all "Quantity" fields go back to 1, and the caret leaves the field.

Pick the line with the bug.

The idea

Filtering and sorting

The catalog shows only the products in stock, sorted by price. This is a derived list: it is not stored separately but made from the data on every render.

const visible = PRODUCTS
.filter((p) => p.stock > 0)
.sort((a, b) => a.price - b.price);

This is in the body of the component: every render makes visible again from PRODUCTS.

  • filter returns a new array with only the products in stock. The original stays whole.
  • sort sorts the array in place. Here it is safe, because it comes after filter, which already gave a copy. Without a filter you make a copy first: [...PRODUCTS].sort(…).
  • The key stays p.id. When sorting, the rows change places, and the index would change the key of every one of them.

Try it

Only what is in stock

Show only the products in stock (stock > 0), sorted by price from the cheapest. Above the list write "In stock: 3 of 5".

React
More precisely, if you are curious

key is not a prop. The library takes the key for itself and does not pass it to the component. props.key in ProductCard is undefined, and React logs the error "key is not a prop". If the component needs the id, pass it separately: product already contains it.

A key to reset. A new key means a new element. So a changed key is a way to reset a component: a form keyed by the id of the product starts empty for every new product.

Several elements per row. If one product is two elements (<dt> and <dd>), the key goes on the wrapper: <Fragment key={p.id}>…</Fragment>. The short form <>…</> does not take a key.

Class components. Old React code has components as classes (class ProductList extends Component). Keys work in them the same way.

toSorted. products.toSorted((a, b) => a.price - b.price) returns a sorted copy and does not touch the original. It is in browsers since 2023.

Why the field is "uncontrolled". Here the number in the field is only in the DOM. In the "State" module the field becomes controlled: the number is in the data and follows them. Then the key still matters: for the focus, the caret and the state that the component keeps inside itself.

Exercise

The cart

The state and the "Sort by price" button are ready. Make the row for every product: ProductCard and a "Remove" button whose name for a screen reader is "Remove White bread". The check types quantities, sorts, removes the bread and looks that every number stays with its product.

React

Test

Check yourself

1. How does the library tell which row of the old list is which row of the new one?
2. The key is the index. The buyer removes the first product. What happens to the fields of the other rows?
3. Why is Math.random() a bad key?
4. The list has to be sorted by price. How?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·