Module 8: Project Build Mercados Storefront
The polish: stable `key` and empty states
Overview
The storefront's circuit already works: you load, search, add, remove (lessons 3 to 6). But "it works on the happy path" isn't the same as "it's finished". This last construction layer is the polish: the two details that separate a demo from an app that holds up under real use. The first is the stable key={id} in lists: when the list filters or reorders, the right key is what keeps each row's local state stuck to the right product, not to the position. The second is the empty states: when a search finds nothing or the cart has no items, the app should say so —"No products match your search", "Your cart is empty"— instead of leaving a blank area that looks broken. Neither one "adds functionality"; both keep the app from feeling half-done.
Connection with the module. It's the capstone's sixth and last construction layer, and it refines with two modules. The key —stable identity, id and not index— is module 2 (lesson 6), with the consequences that module 5 (lesson 6) showed when reordering. The empty states are conditional rendering (M2) over a derived value —"is the list empty?" is computed from its length, not stored— (M5). It's applied on top of everything before: the list that can end up empty is the one derived in lesson 4; the cart that can be empty is the one from lessons 5-6. The polish goes at the end on purpose: there's no point caring about the key of cards without local state, nor the empty state of a list that isn't filtered yet. First we had to build; now we polish.
An analogy: the labels on a move's boxes
Imagine you pack your house into identical boxes for a move. You have two options to know what's in each one. The bad one: memorize the order —"the third box has the plates"—. It works until someone moves the boxes: as soon as they reorder the row, "the third box" is now the books, and everything you knew is wrong. The good one: stick on each box a label with its contents —"plates", "books", "clothes"—. Now, however they move the boxes, the label travels with the box: the "plates" one keeps being the plates one whether it ends up first, last, or in the middle. The box's identity is in its label, not in its place in the row.
In React, a list's rows are the boxes, and the key is the label. If you use the index as the key (key={i}), you're memorizing the order: row "number 2" is whichever one is in position 2. As soon as the list reorders or filters, React gets confused just like you with the moved boxes —a row's local state (a half-typed input, a checked checkbox) stays stuck to the position, not to the product—. If you use the product's id as the key (key={product.id}), the label travels with the box: "Wireless Mouse"'s state follows "Wireless Mouse" wherever the list sends it. The identity is in the id, not in the index. That's the whole difference, and it costs the same to write.
Worked example: the empty states and the stable key
Let's go to the polish's two pieces, executed. First, how the handling of empty states looks in real React —the key you'd already been putting since lesson 2—:
function ProductList({ products, onAddToCart }) {
// the list's empty state: derived from the length, not stored
if (products.length === 0) {
return <p className="empty-state">No products match your search</p>;
}
return (
<section className="product-list">
{products.map((product) => (
<ProductCard key={product.id} product={product} onAddToCart={onAddToCart} />
// key = id, not the index: the identity travels with the product
))}
</section>
);
}
function Cart({ items, onRemoveFromCart }) {
return (
<aside className="cart">
<h2 className="cart-title">Your cart</h2>
{items.length === 0 ? (
<p className="empty-state">Your cart is empty</p> // the cart's empty state
) : (
<ul className="cart-items">
{items.map((line) => (
<CartItem key={line.id} line={line} onRemoveFromCart={onRemoveFromCart} />
))}
</ul>
)}
<p className="cart-total">Total: {formatPrice(cartTotal(items))}</p>
</aside>
);
}
Notice the two details. The empty states come from a conditional (M2) over a derived value (M5): "is the list empty?" is products.length === 0, computed in the render, not a stored state; the same for the cart. And the key={product.id} / key={line.id} uses the id, not the index.
Now let's execute the two things. First, the empty state rendered: we ask the storefront to paint with a search that has no results and an empty cart, and we look at the HTML —using the guide's mini renderToString—:
// ... renderToString, formatPrice, cartTotal, getVisibleProducts and the six components
// (App derives the list with getVisibleProducts, as in lesson 4) ...
const PRODUCTS = [
{ id: 'p1', name: 'Wireless Mouse', priceCents: 2599, category: 'peripherals', inStock: true },
{ id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900, category: 'peripherals', inStock: false },
{ id: 'p3', name: 'USB-C Hub', priceCents: 3499, category: 'peripherals', inStock: true },
{ id: 'p4', name: 'Laptop Stand', priceCents: 4500, category: 'furniture', inStock: true },
{ id: 'p5', name: 'Desk Lamp', priceCents: 1999, category: 'furniture', inStock: true },
];
console.log('=== 1) Empty state: a search with no results and an empty cart ===');
console.log(renderToString(App({ products: PRODUCTS, query: 'laptop pro', cart: [] })));
And second, the stable key in the face of reordering. We model what React does when reconciling a list between renders: the local state (here, a "marked" row) is carried over from the old list to the new one by the key. We compare key = id against key = index when the list reorders:
function reconcile(oldRows, newList, keyOf) {
const byKey = new Map(oldRows.map((r) => [r.key, r]));
return newList.map((p, i) => {
const key = keyOf(p, i);
const prev = byKey.get(key);
return { product: p, checked: prev ? prev.checked : false };
});
}
const listA = [PRODUCTS[4], PRODUCTS[0], PRODUCTS[2], PRODUCTS[3]]; // Desk Lamp, Wireless Mouse, USB-C Hub, Laptop Stand
const listB = [...listA].reverse(); // the catalog reloads in another order
const markedById = listA.map((p) => ({ key: p.id, checked: p.name === 'Wireless Mouse' }));
const markedByIx = listA.map((p, i) => ({ key: i, checked: p.name === 'Wireless Mouse' }));
console.log('\n=== 2) key = id preserves the local state when the list reorders ===');
console.log('The user marks "Wireless Mouse" and the list reorders.');
console.log('\nwith key = product.id (correct):');
for (const r of reconcile(markedById, listB, (p) => p.id)) console.log(` [${r.checked ? 'x' : ' '}] ${r.product.name}`);
console.log('\nwith key = index (breaks):');
for (const r of reconcile(markedByIx, listB, (p, i) => i)) console.log(` [${r.checked ? 'x' : ' '}] ${r.product.name}`);
What to expect. When you run the file with Node, the output is exactly this:
=== 1) Empty state: a search with no results and an empty cart ===
<main class="storefront">
<div class="search-bar">
<label for="product-search">Search products</label>
<input id="product-search" type="search" class="search-input" placeholder="Search products..." value="laptop pro" />
</div>
<p class="empty-state">No products match your search</p>
<aside class="cart">
<h2 class="cart-title">Your cart</h2>
<p class="empty-state">Your cart is empty</p>
<p class="cart-total">Total: $0.00</p>
</aside>
</main>
=== 2) key = id preserves the local state when the list reorders ===
The user marks "Wireless Mouse" and the list reorders.
with key = product.id (correct):
[ ] Laptop Stand
[ ] USB-C Hub
[x] Wireless Mouse
[ ] Desk Lamp
with key = index (breaks):
[ ] Laptop Stand
[x] USB-C Hub
[ ] Wireless Mouse
[ ] Desk Lamp
Read the two parts.
1) The empty states. With query = "laptop pro" (which matches no name) and cart = [], the storefront leaves no blank area. Instead of the <section class="product-list">, ProductList took its early return and painted <p class="empty-state">No products match your search</p>. And the Cart, with empty items, painted <p class="empty-state">Your cart is empty</p> instead of the <ul>, with the honest total at $0.00. The search-bar still shows value="laptop pro" (the controlled input reflects what the user typed). The user sees exactly what's happening —"no results", "the cart is empty"—, not a mysteriously blank screen. Note that "is it empty?" wasn't stored in any state: it was derived from the length (products.length === 0, items.length === 0) in the render.
2) The stable key. The user marks "Wireless Mouse" in the list, and then the list reorders (the catalog reloaded in reverse order, say). Look at the two columns:
- With
key = product.id: the mark followed "Wireless Mouse" to its new position (third). The label traveled with the box: the local state (the mark) stayed stuck to the product, not to the place. Correct. - With
key = index: the mark stayed in the position that "Wireless Mouse" used to occupy (the second), which after the reordering is "USB-C Hub". The mark jumped to the wrong product. React reconciled by position, not by identity, and the local state stuck to the one it shouldn't.
That [x] USB-C Hub in the second column —a product the user never marked, now marked— is the exact bug that key = index causes in lists that reorder or filter. And the storefront reorders and filters on every search: it's the worst possible place for the index. The key = id costs the same to write and doesn't have that problem. That's why you put it since lesson 2, and why it matters to verify it at the end.
Going deeper: why the polish matters and goes at the end
The empty states are part of the app, not an extra. A list and a cart have more than one possible state: with data, and empty. During development there's almost always data, so the empty state is the one that gets forgotten —and the one the user finds on day one, searching for something that doesn't exist—. A well-made empty state does three things: it confirms the app works (it's not hung), it explains what happened ("no results"), and sometimes it suggests what to do ("try another search"). Treating it as part of the component —one more branch of the render, triggered by a derived value— is what avoids the dreaded "blank screen".
The key is identity, not order. React uses the key to reconcile: between one render and the next, it decides which row from before corresponds to which row now, and thus preserves its local state (inputs, focus, animations) and does the minimum work in the DOM. If the key is stable and unique per element (the id), that correspondence is correct even if the list reorders or filters. If the key is the index, the correspondence is "same position", which breaks as soon as the order changes. The right key is the one that answers "is this thing the same as before?", and the answer is given by the id, not the place.
When the index as key is acceptable (and when it isn't). The index works only if the list never reorders, never filters, and never inserts/removes elements in the middle —a static, read-only list, in fixed order—. As soon as the list changes order or content, the index lies. The storefront's ProductList filters and sorts on every search, and the Cart gains and loses lines: both are cases where the index fails. The practical rule: use the id by default; reserve the index only for lists you guarantee are immutable in order and content. When in doubt, id.
Why the polish goes at the end. It's not that the polish is less important; it's that it needs something to polish. You can't test the empty state of a list that isn't filtered yet, nor verify the key of cards that don't yet have local state nor reorder. The healthy build order is structure → behavior → refinement: first the tree (lesson 2), then the loading, the search, the cart and the cables (3 to 6), and at the end the details that harden the app at its edges. Putting the polish at the end doesn't make it optional: it makes it possible.
Common mistakes
Forgetting the empty state (testing only with data). What happens: you always test the storefront with products and with the cart full, and in production a search with no results leaves the list blank, or a freshly-opened cart looks broken. Why it happens: during development data is almost never missing. How to spot it: you filter by something nonexistent (or open the empty cart) and no message appears, just a gap. How to fix it: test both states —with data and empty— as in the example. The "no results" early return and the "empty cart" ternary are part of the component, not an ornament.
Using the index as key "because the list is short". What happens: products.map((p, i) => <ProductCard key={i} ... />). Why it happens: with few products it seems not to matter. How to spot it: as soon as a row has local state (an input, a checkbox) and the list filters or reorders, that state jumps to the wrong row —like the [x] USB-C Hub from the example—. How to fix it: key={product.id} always. The list's size doesn't matter; what matters is whether it changes order or content, and the ProductList changes on every search. The id costs the same.
Putting the key on the inner element instead of the one the .map() returns. What happens: on refactoring, the key ends up on the <article> inside the ProductCard instead of the <ProductCard> that the .map() returns, and the "unique key prop" warning comes back. Why it happens: the key gets misplaced when moving the .map() between components. How to spot it: React warns even though you "swear it was already there". How to fix it: the key goes on the element the .map() returns directly —the <ProductCard> or the <CartItem>—, not on a child of it nor on the <section>/<ul> container. After any refactor, verify it's still there.
Exercises
Exercise 1 — Predict the empty state. Without running anything, say what HTML the <aside class="cart"> produces and what the list block produces if you call App({ products: PRODUCTS, query: 'zzz', cart: [] }). Explain what value each empty state is derived from.
See solution
With query = 'zzz' (no name contains it), getVisibleProducts returns [], so ProductList takes its early return:
<p class="empty-state">No products match your search</p>
And with cart = [], the Cart takes the empty-cart branch:
<aside class="cart">
<h2 class="cart-title">Your cart</h2>
<p class="empty-state">Your cart is empty</p>
<p class="cart-total">Total: $0.00</p>
</aside>
Each empty state is derived from a length computed in the render: the list, from visible.length === 0 (where visible = getVisibleProducts(products, 'zzz') is []); the cart, from items.length === 0. Neither is stored in state: they're consequences, computed each time.
Exercise 2 — Trace the key when reordering. In the example, listA = [Desk Lamp, Wireless Mouse, USB-C Hub, Laptop Stand] and listB is its reverse. The user marked "Wireless Mouse" (position 2 in listA). Without running anything, say where the mark ends up with key = index and why it lands on "USB-C Hub".
See solution
With key = index, the state is stored by position: in listA, "Wireless Mouse" was at index 1 (second row), so the mark was associated with index 1, not with the product.
When reordering to listB (the reverse: [Laptop Stand, USB-C Hub, Wireless Mouse, Desk Lamp]), React reconciles by index: the row at index 1 of listB is "USB-C Hub". Since the mark was at "index 1", "USB-C Hub" takes it, even though the user never marked it. "Wireless Mouse" (now at index 2) ends up unmarked.
With key = id, on the other hand, the mark is associated with "Wireless Mouse"'s id, so it follows it to index 2 of listB: [x] Wireless Mouse, correct. The identity travels with the product (the id), not with the position (the index).
Exercise 3 — Improve the empty state. The current empty state just says "No products match your search". Design it a bit more usefully: have it show what was searched and offer to clear. Write the ProductList's JSX for its empty branch, receiving the query and an onClear.
See solution
function ProductList({ products, query, onAddToCart, onClear }) {
if (products.length === 0) {
return (
<div className="empty-state">
<p>No products match "{query}".</p>
<button className="clear-btn" onClick={onClear}>Clear search</button>
</div>
);
}
return (
<section className="product-list">
{products.map((product) => (
<ProductCard key={product.id} product={product} onAddToCart={onAddToCart} />
))}
</section>
);
}
Now the empty state does the three things of a good empty state: it confirms the app responded, it explains what happened (showing the query that found nothing), and it suggests an action (the "Clear search" button, which calls onClear —the same onSearch('') from lesson 4—). The query it shows is the source state; that the list is empty is still derived (products.length === 0). A useful empty state turns a dead end into a next step.
Summary and next step
In this lesson you gave the storefront its polish: the two details that separate a demo from a finished app. The stable key={id}, which keeps the local state stuck to the right product when the list filters or reorders —you anchored it with the labels on a move's boxes (the identity travels with the box, not with its place), and you executed it seeing how key = id follows "Wireless Mouse" while key = index leaves the mark on the wrong product—. And the empty states —"No products match your search", "Your cart is empty"—, conditionally rendered (M2) over values derived from the length (M5), which avoid the blank screen. You saw the HTML of the two empty states come out of Node, with no gap anywhere.
Before moving on you should be able to: use key={id} (never the index) in lists that change order or content, and explain the index bug; handle the empty states as a branch of the render triggered by a derived value; and explain why the polish goes at the end (it needs something to polish).
With this, the six construction layers are complete: the scaffolding (2), the loading (3), the derived search (4), the cart with a reducer (5), the cables (6) and the polish (7). The storefront is finished. Lesson 8 is the deliverable and the guide's closing: the project's statement, the rubric to self-assess, the complete reference solution (the whole App in real React), the logic executed in Node end to end (the cartReducer + the derivation + the total, and the storefront rendered to HTML), and the map of where to go next in the Fullstack ecosystem. Time to put it all together and deliver.
Resources
- React, "Rendering Lists: Keeping list items in order with key" — react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key. What the
keyis, why it must be stable and unique, and why the index fails when reordering —the heart of this lesson—. In English. - React, "Rendering Lists: Pitfall (index as key)" — react.dev/learn/rendering-lists. The official warning about using the index as
keyand what breaks with the local state. In English. - React, "Conditional Rendering" — react.dev/learn/conditional-rendering. How to paint one branch or another (
&&, ternary, early return) —the empty states' mechanism—. In English. - React, "Preserving and Resetting State" — react.dev/learn/preserving-and-resetting-state. How React preserves or resets the local state according to the position and the
key—why the rightkeykeeps each row's state in its place—. In English.