Lists, keys and rendering collections
Almost every screen renders a list of something. The mechanics take two minutes;
the key prop takes longer and causes real bugs when misunderstood.
Rendering a list
export function ProductGrid({ products }: { products: Product[] }) {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
.map() turns an array of data into an array of elements, and React renders
arrays by rendering each item.
.map() rather than a for loop because JSX takes expressions, not statements —
the same reason conditionals use ? : and &&.
Always handle empty
export function ProductGrid({ products }: { products: Product[] }) {
if (products.length === 0) {
return (
<div className="py-16 text-center">
<p className="text-gray-500">No products match your filters.</p>
<button onClick={onClearFilters} className="mt-3 text-emerald-600">
Clear filters
</button>
</div>
);
}
return <div className="grid gap-4">{products.map(...)}</div>;
}
An empty array renders nothing, which looks like a broken page. Every list needs an empty state, and a useful one says what to do next rather than only "nothing here". Filters that return nothing should offer to clear themselves.
Keys
React warns if you leave them out:
Warning: Each child in a list should have a unique "key" prop.
The key tells React which element corresponds to which item between renders. It uses that to decide what to update, move, or discard.
The key must be stable and unique among siblings. A database id is ideal:
<ProductCard key={product.id} product={product} />
Why the index is usually wrong
{products.map((product, index) => (
<ProductCard key={index} product={product} />
))}
This silences the warning and introduces a bug that appears only when the list changes.
Say you render three items keyed 0, 1, 2, and remove the first. Now the
remaining two are keyed 0 and 1. React sees keys 0 and 1 still present
and concludes the first two items are unchanged — so it keeps their state and
DOM, and only removes the third.
The result: state attached to the wrong row. A quantity typed against the first item now belongs to the second. A checked checkbox moves. A focused input loses focus or keeps it wrongly.
This is the most common React bug that does not throw an error, and it is invisible until the list reorders.
Index keys are acceptable only when the list never changes order, never has items inserted or removed, and items have no state. A static list of footer links qualifies. Almost nothing else does.
No id? Use a field combination that is unique, or generate ids when the data is created — not during render, which produces a new key every time and throws away all state.
Keys are for React, not for you
<ProductCard key={product.id} product={product} />
The card cannot read key as a prop. It is consumed by React. If the component
needs the id, pass it separately.
Keys are also scoped to siblings, so two different lists on a page may use the same keys safely.
Using a key deliberately
Changing a key destroys a component and builds a new one, discarding its state — the trick from the effects lesson:
<ProductDetail key={product.id} product={product} />
Navigating to another product now resets the quantity picker, the selected variant and anything else, with no effect and no extra render.
Use it knowingly. An unintentional key change is a component that loses its state for no apparent reason.
Filtering and sorting
const visible = products
.filter((p) => !category || p.category === category)
.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.pricePaise - b.pricePaise);
Both return new arrays, so state is not mutated.
sort mutates. [...products].sort(...) when the source is state or props,
or you will mutate something you do not own. .filter() before .sort() avoids
it here by accident — do not rely on accident.
Where this runs matters, from module 1. Filtering fifty products in the browser is fine. Filtering five thousand means sending five thousand to a phone; that belongs in the API, which is module 7.
Nested lists
{categories.map((category) => (
<section key={category.id}>
<h2>{category.name}</h2>
{category.products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</section>
))}
Both levels need keys. Fragments do too when they are the mapped element:
{items.map((item) => (
<React.Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.definition}</dd>
</React.Fragment>
))}
The shorthand <> cannot take a key, so this is the one place the long form is
required.
Long lists
Rendering ten thousand rows creates ten thousand DOM nodes and a slow page. Options, in order of preference: paginate — which the API should support anyway; load more on scroll; virtualise, rendering only what is visible, with a library like TanStack Virtual.
Do not reach for virtualisation early. Pagination is simpler, better for SEO, and usually what users want.
Check your work
What a key is for: telling React which element corresponds to which item between renders, so it can update, move or discard correctly.
Why index keys corrupt state: removing an item shifts every later index, so React believes the remaining rows are the same ones and keeps their state attached to the wrong data.
When an index key is acceptable: the list never reorders, never has items added or removed, and its items have no state.
Why a component cannot read its own key: React consumes it. Pass the id
separately if the component needs it.
Using a key deliberately: changing it destroys the component and its state, which is how you reset a detail view when navigating between records.
Why .sort() on state is a bug: it mutates in place. Use
[...items].sort(...).
Why every list needs an empty state: an empty array renders nothing, which looks like a broken page.
Why fragments in a list need React.Fragment: the shorthand <> cannot
take a key.
Practice
- Render a grid of products with
.map(). Remove the key and read the warning. - Add an empty state that appears when the array is empty, with a way out.
- Use
key={index}on a list with a text input in each row. Type in the first, delete it, and watch your text move. - Fix it with a stable id and confirm the bug is gone.
- Add
key={product.id}to a detail component and confirm changing product resets its internal state. - Sort a list held in state with
.sort()directly. Find the mutation, then fix it with a spread. - Build a nested category/product list with keys at both levels.
- Render a list of term/definition pairs needing
React.Fragmentwith a key.
Next: composition — building components that stay reusable.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship