Módulo 8: Project Build Mercados Storefront
El pulido: `key` estable y estados vacíos
Descripción
El circuito del storefront ya funciona: cargas, buscas, agregas, quitas (lecciones 3 a 6). Pero "funciona en el camino feliz" no es lo mismo que "está terminado". Esta última capa de construcción es el pulido: los dos detalles que separan un demo de una app que aguanta el uso real. El primero es la key={id} estable en las listas: cuando la lista se filtra o se reordena, la key correcta es lo que mantiene el estado local de cada fila pegado al producto correcto, no a la posición. El segundo son los estados vacíos: cuando una búsqueda no encuentra nada o el carrito está sin items, la app debe decirlo —"No products match your search", "Your cart is empty"— en lugar de dejar una zona en blanco que parece rota. Ninguno de los dos "agrega funcionalidad"; los dos evitan que la app se sienta a medias.
Conexión con el módulo. Es la sexta y última capa de construcción del capstone, y refina con dos módulos. La key —identidad estable, id y no índice— es el módulo 2 (lección 6), con las consecuencias que el módulo 5 (lección 6) mostró al reordenar. Los estados vacíos son renderizado condicional (M2) sobre un valor derivado —"¿la lista está vacía?" se computa de su longitud, no se guarda— (M5). Se aplica sobre todo lo anterior: la lista que puede quedar vacía es la derivada de la lección 4; el carrito que puede estar vacío es el de las lecciones 5-6. El pulido va al final a propósito: no tiene sentido cuidar la key de tarjetas sin estado local, ni el estado vacío de una lista que aún no se filtra. Primero hubo que construir; ahora se pule.
Una analogía: las etiquetas de las cajas de una mudanza
Imagina que embalas tu casa en cajas idénticas para una mudanza. Tienes dos opciones para saber qué hay en cada una. La mala: memorizar el orden —"la tercera caja tiene los platos"—. Funciona hasta que alguien mueve las cajas: en cuanto reordenan la fila, "la tercera caja" ya son los libros, y todo lo que sabías está mal. La buena: pegarle a cada caja una etiqueta con su contenido —"platos", "libros", "ropa"—. Ahora, muevan las cajas como las muevan, la etiqueta viaja con la caja: la de "platos" sigue siendo la de los platos aunque quede primera, última o en medio. La identidad de la caja está en su etiqueta, no en su lugar en la fila.
En React, las filas de una lista son las cajas, y la key es la etiqueta. Si usas el índice como key (key={i}), estás memorizando el orden: la fila "número 2" es la que esté en la posición 2, sea cual sea. En cuanto la lista se reordena o se filtra, React se confunde igual que tú con las cajas movidas —el estado local de una fila (un input a medio escribir, un checkbox marcado) se queda pegado a la posición, no al producto—. Si usas el id del producto como key (key={product.id}), la etiqueta viaja con la caja: el estado de "Wireless Mouse" sigue a "Wireless Mouse" a donde sea que la lista lo mande. La identidad está en el id, no en el índice. Esa es toda la diferencia, y se paga con lo mismo de escribir.
Ejemplo trabajado: los estados vacíos y la key estable
Vamos a las dos piezas del pulido, ejecutadas. Primero, cómo se ve en React real el manejo de los estados vacíos —la key ya la venías poniendo desde la lección 2—:
function ProductList({ products, onAddToCart }) {
// estado vacio de la lista: derivado de la longitud, no guardado
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, no el indice: la identidad viaja con el producto
))}
</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> // estado vacio del carrito
) : (
<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>
);
}
Fíjate en los dos detalles. Los estados vacíos salen de un condicional (M2) sobre un valor derivado (M5): "¿la lista está vacía?" es products.length === 0, calculado en el render, no un estado guardado; igual para el carrito. Y la key={product.id} / key={line.id} usa el id, no el índice.
Ahora ejecutemos las dos cosas. Primero, el estado vacío renderizado: pedimos al storefront que se pinte con una búsqueda sin resultados y un carrito vacío, y miramos el HTML —usando el mini renderToString de la guía—:
// ... renderToString, formatPrice, cartTotal, getVisibleProducts y los seis componentes
// (App deriva la lista con getVisibleProducts, como en la leccion 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) Estado vacio: una busqueda sin resultados y un carrito vacio ===');
console.log(renderToString(App({ products: PRODUCTS, query: 'laptop pro', cart: [] })));
Y segundo, la key estable frente al reordenamiento. Modelamos lo que React hace al reconciliar una lista entre renders: el estado local (aquí, una fila "marcada") se traspasa de la lista vieja a la nueva por la key. Comparamos key = id contra key = index cuando la lista se reordena:
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(); // el catalogo se recarga en otro orden
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 conserva el estado local al reordenar la lista ===');
console.log('El usuario marca "Wireless Mouse" y la lista se reordena.');
console.log('\ncon key = product.id (correcto):');
for (const r of reconcile(markedById, listB, (p) => p.id)) console.log(` [${r.checked ? 'x' : ' '}] ${r.product.name}`);
console.log('\ncon key = index (se rompe):');
for (const r of reconcile(markedByIx, listB, (p, i) => i)) console.log(` [${r.checked ? 'x' : ' '}] ${r.product.name}`);
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== 1) Estado vacio: una busqueda sin resultados y un carrito vacio ===
<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 conserva el estado local al reordenar la lista ===
El usuario marca "Wireless Mouse" y la lista se reordena.
con key = product.id (correcto):
[ ] Laptop Stand
[ ] USB-C Hub
[x] Wireless Mouse
[ ] Desk Lamp
con key = index (se rompe):
[ ] Laptop Stand
[x] USB-C Hub
[ ] Wireless Mouse
[ ] Desk Lamp
Lee las dos partes.
1) Los estados vacíos. Con query = "laptop pro" (que no coincide con ningún nombre) y cart = [], el storefront no deja ninguna zona en blanco. En vez de la <section class="product-list">, ProductList tomó su early return y pintó <p class="empty-state">No products match your search</p>. Y el Cart, con items vacíos, pintó <p class="empty-state">Your cart is empty</p> en lugar de la <ul>, con el total honesto en $0.00. La search-bar sigue mostrando value="laptop pro" (el input controlado refleja lo que el usuario escribió). El usuario ve exactamente qué pasa —"no hay resultados", "el carrito está vacío"—, no una pantalla misteriosamente vacía. Nota que "¿está vacío?" no se guardó en ningún estado: se derivó de la longitud (products.length === 0, items.length === 0) en el render.
2) La key estable. El usuario marca "Wireless Mouse" en la lista, y luego la lista se reordena (el catálogo se recargó en orden inverso, digamos). Mira las dos columnas:
- Con
key = product.id: la marca siguió a "Wireless Mouse" hasta su nueva posición (tercera). La etiqueta viajó con la caja: el estado local (la marca) se quedó pegado al producto, no al lugar. Correcto. - Con
key = index: la marca se quedó en la posición que ocupaba "Wireless Mouse" antes (la segunda), que tras el reordenamiento es "USB-C Hub". La marca saltó al producto equivocado. React reconcilió por posición, no por identidad, y el estado local se pegó a quien no debía.
Ese [x] USB-C Hub de la segunda columna —un producto que el usuario nunca marcó, ahora marcado— es el bug exacto que la key = index provoca en listas que se reordenan o filtran. Y el storefront se reordena y se filtra en cada búsqueda: es el peor lugar posible para el índice. La key = id cuesta lo mismo de escribir y no tiene ese problema. Por eso la pusiste desde la lección 2, y por eso importa comprobarla al final.
Profundización: por qué el pulido importa y va al final
Los estados vacíos son parte de la app, no un extra. Una lista y un carrito tienen más de un estado posible: con datos, y vacíos. Durante el desarrollo casi siempre hay datos, así que el estado vacío es el que se olvida —y el que el usuario encuentra el primer día, buscando algo que no existe—. Un estado vacío bien hecho hace tres cosas: confirma que la app funciona (no está colgada), explica qué pasó ("no hay resultados"), y a veces sugiere qué hacer ("prueba otra búsqueda"). Tratarlo como parte del componente —una rama más del render, disparada por un valor derivado— es lo que evita la temida "pantalla en blanco".
La key es identidad, no orden. React usa la key para reconciliar: entre un render y el siguiente, decide qué fila de antes corresponde a qué fila de ahora, y así conserva su estado local (inputs, foco, animaciones) y hace el mínimo trabajo en el DOM. Si la key es estable y única por elemento (el id), esa correspondencia es correcta aunque la lista se reordene o filtre. Si la key es el índice, la correspondencia es "misma posición", que se rompe en cuanto el orden cambia. La key correcta es la que responde a "¿es esta cosa la misma que antes?", y la respuesta la da el id, no el lugar.
Cuándo el índice como key es aceptable (y cuándo no). El índice funciona solo si la lista nunca se reordena, nunca se filtra, y nunca se insertan/eliminan elementos en el medio —una lista estática, de solo lectura, en orden fijo—. En cuanto la lista cambia de orden o de contenido, el índice miente. El ProductList del storefront se filtra y ordena en cada búsqueda, y el Cart gana y pierde líneas: los dos son casos donde el índice falla. La regla práctica: usa el id por defecto; reserva el índice solo para listas que garantizas inmutables en orden y contenido. Ante la duda, id.
Por qué el pulido va al final. No es que el pulido sea menos importante; es que necesita algo que pulir. No puedes probar el estado vacío de una lista que aún no se filtra, ni comprobar la key de tarjetas que aún no tienen estado local ni se reordenan. El orden sano de construcción es estructura → comportamiento → refinamiento: primero el árbol (lección 2), luego la carga, la búsqueda, el carrito y los cables (3 a 6), y al final los detalles que endurecen la app en sus bordes. Poner el pulido al final no lo hace opcional: lo hace posible.
Errores comunes
Olvidar el estado vacío (probar solo con datos). Qué pasa: se prueba el storefront siempre con productos y con el carrito lleno, y en producción una búsqueda sin resultados deja la lista en blanco, o un carrito recién abierto se ve roto. Por qué pasa: durante el desarrollo casi nunca falta data. Cómo detectarlo: filtras por algo inexistente (o abres el carrito vacío) y no aparece ningún mensaje, solo un hueco. Cómo corregirlo: prueba ambos estados —con datos y vacío— como en el ejemplo. El early return de "sin resultados" y el ternario de "carrito vacío" son parte del componente, no un adorno.
Usar el índice como key "porque la lista es corta". Qué pasa: products.map((p, i) => <ProductCard key={i} ... />). Por qué pasa: con pocos productos parece dar igual. Cómo detectarlo: en cuanto una fila tiene estado local (un input, un checkbox) y la lista se filtra o reordena, ese estado salta a la fila equivocada —como el [x] USB-C Hub del ejemplo—. Cómo corregirlo: key={product.id} siempre. El tamaño de la lista no importa; lo que importa es si cambia de orden o contenido, y el ProductList cambia en cada búsqueda. El id cuesta lo mismo.
Poner la key en el elemento interno en vez del que devuelve el .map(). Qué pasa: al refactorizar, la key termina en el <article> de dentro del ProductCard en lugar del <ProductCard> que el .map() devuelve, y vuelve la advertencia "unique key prop". Por qué pasa: la key se traspapela al mover el .map() entre componentes. Cómo detectarlo: React avisa aunque "juras que ya estaba". Cómo corregirlo: la key va en el elemento que el .map() devuelve directamente —el <ProductCard> o el <CartItem>—, no en un hijo suyo ni en el contenedor <section>/<ul>. Después de cualquier refactor, verifica que sigue ahí.
Ejercicios
Ejercicio 1 — Predice el estado vacío. Sin correr nada, di qué HTML produce el <aside class="cart"> y qué produce el bloque de la lista si llamas App({ products: PRODUCTS, query: 'zzz', cart: [] }). Explica de qué valor se deriva cada estado vacío.
Ver solución
Con query = 'zzz' (ningún nombre lo contiene), getVisibleProducts devuelve [], así que ProductList toma su early return:
<p class="empty-state">No products match your search</p>
Y con cart = [], el Cart toma la rama del carrito vacío:
<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>
Cada estado vacío se deriva de una longitud calculada en el render: la lista, de visible.length === 0 (donde visible = getVisibleProducts(products, 'zzz') es []); el carrito, de items.length === 0. Ninguno se guarda en estado: son consecuencias, computadas cada vez.
Ejercicio 2 — Traza la key al reordenar. En el ejemplo, listA = [Desk Lamp, Wireless Mouse, USB-C Hub, Laptop Stand] y listB es su reverso. El usuario marcó "Wireless Mouse" (posición 2 en listA). Sin correr nada, di dónde queda la marca con key = index y por qué termina en "USB-C Hub".
Ver solución
Con key = index, el estado se guarda por posición: en listA, "Wireless Mouse" estaba en el índice 1 (segunda fila), así que la marca quedó asociada al índice 1, no al producto.
Al reordenar a listB (el reverso: [Laptop Stand, USB-C Hub, Wireless Mouse, Desk Lamp]), React reconcilia por índice: la fila del índice 1 de listB es "USB-C Hub". Como la marca estaba en "el índice 1", se la lleva "USB-C Hub", aunque el usuario nunca lo marcó. "Wireless Mouse" (ahora en el índice 2) queda sin marca.
Con key = id, en cambio, la marca está asociada al id de "Wireless Mouse", así que lo sigue hasta el índice 2 de listB: [x] Wireless Mouse, correcto. La identidad viaja con el producto (el id), no con la posición (el índice).
Ejercicio 3 — Mejora el estado vacío. El estado vacío actual dice solo "No products match your search". Diséñalo un poco más útil: que muestre qué se buscó y ofrezca limpiar. Escribe el JSX del ProductList para su rama vacía, recibiendo el query y un onClear.
Ver solución
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>
);
}
Ahora el estado vacío hace las tres cosas de un buen estado vacío: confirma que la app respondió, explica qué pasó (mostrando el query que no encontró nada), y sugiere una acción (el botón "Clear search", que llama onClear —el mismo onSearch('') de la lección 4—). El query que muestra es el estado fuente; que la lista esté vacía sigue siendo derivado (products.length === 0). Un estado vacío útil convierte un callejón sin salida en un siguiente paso.
Resumen y siguiente paso
En esta lección le diste al storefront su pulido: los dos detalles que separan un demo de una app terminada. La key={id} estable, que mantiene el estado local pegado al producto correcto cuando la lista se filtra o reordena —lo anclaste con las etiquetas de las cajas de mudanza (la identidad viaja con la caja, no con su lugar), y lo ejecutaste viendo cómo key = id sigue a "Wireless Mouse" mientras key = index deja la marca en el producto equivocado—. Y los estados vacíos —"No products match your search", "Your cart is empty"—, renderizados condicionalmente (M2) sobre valores derivados de la longitud (M5), que evitan la pantalla en blanco. Viste el HTML de los dos estados vacíos salir de Node, sin ninguna zona en hueco.
Antes de avanzar deberías poder: usar key={id} (nunca el índice) en listas que cambian de orden o contenido, y explicar el bug del índice; manejar los estados vacíos como una rama del render disparada por un valor derivado; y explicar por qué el pulido va al final (necesita algo que pulir).
Con esto, las seis capas de construcción están completas: el andamiaje (2), la carga (3), la búsqueda derivada (4), el carrito con reducer (5), los cables (6) y el pulido (7). El storefront está terminado. La lección 8 es el entregable y el cierre de la guía: el enunciado del proyecto, la rúbrica para autoevaluarte, la solución de referencia completa (el App entero en React real), la lógica ejecutada en Node de punta a punta (el cartReducer + la derivación + el total, y el storefront renderizado a HTML), y el mapa de hacia dónde seguir en el ecosistema Fullstack. Es hora de juntar todo y entregar.
Recursos
- React, "Rendering Lists: Keeping list items in order with key" — react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key. Qué es la
key, por qué debe ser estable y única, y por qué el índice falla al reordenar —el corazón de esta lección—. En inglés. - React, "Rendering Lists: Pitfall (index as key)" — react.dev/learn/rendering-lists. La advertencia oficial sobre usar el índice como
keyy qué se rompe con el estado local. En inglés. - React, "Conditional Rendering" — react.dev/learn/conditional-rendering. Cómo pintar una rama u otra (
&&, ternario, early return) —el mecanismo de los estados vacíos—. En inglés. - React, "Preserving and Resetting State" — react.dev/learn/preserving-and-resetting-state. Cómo React conserva o reinicia el estado local según la posición y la
key—por qué lakeycorrecta mantiene el estado de cada fila en su sitio—. En inglés.