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.
Console
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
- 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.
- "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.
- 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.
- 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.
- The same cart with key = id. The keys are 1, 2, 3 and belong to the product, not to the place.
- "Remove White bread" again. The new list has the keys 2 and 3.
- 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.
- 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.
Console
Solution
App.jsximport { useState } from 'react';
import ProductCard from './ProductCard';
const PRODUCTS = [
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 2, name: 'Fresh milk', price: 1.6 },
{ id: 3, name: 'White cheese', price: 7.9 },
];
export default function App() {
// The state (next module): the list that the buttons change.
const [items, setItems] = useState(PRODUCTS);
const remove = (id) => setItems(items.filter((p) => p.id !== id));
return (
<main>
<h1>The cart</h1>
<ul>
{items.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
<button type="button" aria-label={`Remove ${p.name}`} onClick={() => remove(p.id)}>Remove</button>
</li>
))}
</ul>
</main>
);
}
ProductCard.jsxconst money = (x) => '€' + x.toFixed(2);
export default function ProductCard({ product }) {
return (
<article>
<h2>{product.name}</h2>
<p>{money(product.price)}</p>
<label>
Quantity <input type="number" min="1" defaultValue="1" aria-label={`Quantity: ${product.name}`} />
</label>
</article>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
The key becomes the id of the product. It is the same on every render and different for every product. After "Remove" key 1 is missing, so the row of the bread goes, and the rows of the milk and the cheese stay with their fields. Without a key the library again compares by place and the bug stays.
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>
))}key: React reads it to pair the rows and does not pass it to the component.
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.
Console
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.
Line 3: key={Math.random()} is a new key on every render. No old row has a pair, and React makes all of them again. The key has to be p.id.
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.
filterreturns a new array with only the products in stock. The original stays whole.sortsorts the array in place. Here it is safe, because it comes afterfilter, 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".
Console
Solution
App.jsximport ProductCard from './ProductCard';
const PRODUCTS = [
{ id: 3, name: 'White cheese', price: 7.9, stock: 4 },
{ id: 4, name: 'Banitsa', price: 2.5, stock: 0 },
{ id: 1, name: 'White bread', price: 1.2, stock: 10 },
{ id: 5, name: 'Cucumbers', price: 2.2, stock: 0 },
{ id: 2, name: 'Fresh milk', price: 1.6, stock: 6 },
];
export default function App() {
const visible = PRODUCTS.filter((p) => p.stock > 0).sort((a, b) => a.price - b.price);
return (
<main>
<h1>The catalog</h1>
<p>In stock: {visible.length} of {PRODUCTS.length}</p>
<ul>
{visible.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
</li>
))}
</ul>
</main>
);
}
ProductCard.jsxconst money = (x) => '€' + x.toFixed(2);
export default function ProductCard({ product }) {
return (
<article>
<h2>{product.name}</h2>
<p>{money(product.price)}</p>
<label>
Quantity <input type="number" min="1" defaultValue="1" aria-label={`Quantity: ${product.name}`} />
</label>
</article>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
PRODUCTS.filter((p) => p.stock > 0) drops the banitsa and the cucumbers, and .sort((a, b) => a.price - b.price) orders the bread, the milk and the cheese. The numbers come from the two arrays: visible.length and PRODUCTS.length. The check looks at the order of the headings. It does not see the key: nothing in this list changes after rendering. The key matters as soon as something changes, as in the exercise.
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.
Console
Solution
App.jsximport { useState } from 'react';
import ProductCard from './ProductCard';
const PRODUCTS = [
{ id: 3, name: 'White cheese', price: 7.9 },
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 2, name: 'Fresh milk', price: 1.6 },
];
export default function App() {
// The state (next module): the list that the buttons change.
const [items, setItems] = useState(PRODUCTS);
const remove = (id) => setItems(items.filter((p) => p.id !== id));
const sortByPrice = () => setItems([...items].sort((a, b) => a.price - b.price));
return (
<main>
<h1>The cart ({items.length})</h1>
<ul>
{items.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
<button type="button" aria-label={`Remove ${p.name}`} onClick={() => remove(p.id)}>Remove</button>
</li>
))}
</ul>
<button type="button" onClick={sortByPrice}>Sort by price</button>
</main>
);
}
ProductCard.jsxconst money = (x) => '€' + x.toFixed(2);
export default function ProductCard({ product }) {
return (
<article>
<h2>{product.name}</h2>
<p>{money(product.price)}</p>
<label>
Quantity <input type="number" min="1" defaultValue="1" aria-label={`Quantity: ${product.name}`} />
</label>
</article>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
The row is an <li> with the key p.id, ProductCard and a button. The button calls remove(p.id), and its name for a screen reader comes from aria-label. With the index as the key, sorting moves the numbers: the cheese was first, so the 4 stays on the first row, which is now the bread. With Math.random() every sort sets the fields back to 1.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Events · A list from data · The first component · Props: data from above
- Continues in: Conditions: empty, error, loading · State · Updating objects and arrays · Derived state
- Tasks on this topic: Components