JSX has no loop. You render a list by turning an array of data into an array of elements, which
means map:
<Row xs={1} sm={2} lg={4} className="g-4">
{visibleProducts.map((product) => (
<Col key={product.id}>
<ProductCard product={product} onSelect={handleSelect} />
</Col>
))}
</Row>React accepts an array of elements anywhere a single element goes, and renders them in order.
Leave off the key and it still works — but the console warns, and eventually something
strange happens. This post is about why.
What a key is actually for
When state changes, React has an old element tree and a new one and has to work out the minimum set of DOM operations to get from one to the other. Within a list, position alone is not enough information: if the first item disappears, has everything moved up by one, or did every item's content change?
The key answers that. It is an identity — this element is the same one as before — so React can move a DOM node instead of rebuilding it, and keep whatever state lives inside it.
Keys are never passed to your component. key is consumed by React itself; if the
component needs the id, pass it separately.
// The component receives `product`, not `key`.
<ProductCard key={product.id} product={product} onSelect={handleSelect} />Use a stable id
Something that belongs to the item and does not change when the list is reordered or filtered. A database id is ideal:
{orders.map((order) => (
<tr key={order.id}>
{/* … */}
</tr>
))}When the item has no natural id, make one when the item is created and store it with the data. Cart lines do this — the browser mints a uuid the moment a pizza is added, because "this configuration in this cart" has no server id yet:
dispatch({
type: 'ADD_ITEM',
payload: {
// crypto.randomUUID is built into modern browsers — no library needed.
lineId: crypto.randomUUID(),
productId: input.product.id,
/* … */
},
});{items.map((item) => (
<div key={item.lineId} className="border-bottom pb-3">
{/* … */}
</div>
))}Note it cannot be item.productId: a cart may hold two Pepperonis with different
toppings, and those are different lines. Keys must be unique among their siblings.
Do not generate the key during render. key={crypto.randomUUID()}
inside the map produces a new key every render, so every item looks new, and React
throws the entire list away and rebuilds it every single time. That is worse than no key at all.
The index trap
This is what everyone writes first, and what the warning tempts you into:
{items.map((item, index) => (
<CartLine key={index} item={item} /> // works until the list changes
))}The key of the first item is 0 whatever that item happens to be. So when you delete
the first cart line, React sees key 0 still present with different content and concludes
the item did not move — it changed. It reuses that DOM node and everything inside it.
For read-only text you may never notice. The moment a row contains state, you do:
- Remove the first of three lines and the quantity dropdown of the deleted row is now attached to the second row.
- Type into a row's input, sort the list, and your text follows the position rather than the item.
- A row mid-animation animates the wrong item.
The index is safe only when all three of these hold: the list never reorders, items are never
inserted or removed except at the end, and the items have no state or uncontrolled inputs. That is a
static list — and a static list usually does not need map at all.
Keys are scoped to siblings
They only have to be unique within one map. Two different lists may both use
key={0} without any interaction:
{TOPPING_GROUPS.map((group) => (
<div key={group.category} className="mb-3">
<div className="small fw-semibold mb-1">{group.label}</div>
<div className="d-flex flex-wrap gap-2">
{toppings.filter((t) => t.category === group.category).map((topping) => (
<Button key={topping.id} /* … */>
{topping.name}
</Button>
))}
</div>
</div>
))}Two nested maps, two independent key spaces.
The key goes on the outermost element of the map
A common slip is putting it on the component inside the wrapper:
// WRONG — the array elements are the <Col>s, so that is what needs the key.
{visibleProducts.map((product) => (
<Col>
<ProductCard key={product.id} product={product} onSelect={handleSelect} />
</Col>
))}
// Right.
{visibleProducts.map((product) => (
<Col key={product.id}>
<ProductCard product={product} onSelect={handleSelect} />
</Col>
))}If the outermost thing is a fragment, the shorthand cannot carry a key — use
<Fragment key={…}>.
Keys as a deliberate reset
One more use, worth knowing because it looks like a hack and is actually the idiomatic answer. Changing a component's key destroys it and mounts a fresh one, discarding its state. That is occasionally exactly what you want:
{/* A new product means a genuinely new form — no toppings carried over. */}
<PizzaBuilderModal key={selectedProduct?.id} product={selectedProduct} onHide={close} />The alternative is an effect that resets every field when the product changes, which is what this app does — it needs the reset to be selective. Both are legitimate; the key version is shorter when you want everything reset.