Módulo 8: Project Build Mercados Storefront
El andamiaje: monta el `App` y su árbol
Descripción
Toda construcción empieza por la estructura. Antes de conectar la corriente, el agua o los acabados, se levanta el esqueleto: las columnas, las vigas, los muros que definen los cuartos. En esta lección levantas el esqueleto del storefront: los seis componentes (App, SearchBar, ProductList, ProductCard, Cart, CartItem) compuestos en un árbol, alimentados con datos fijos que bajan por props. Todavía no hay estado, ni eventos, ni carga de datos, ni derivación —eso llega en las capas siguientes—. Hoy montas la foto fija: el storefront completo, catálogo y carrito, descrito una vez y renderizado con datos que le damos a mano.
Esta es, deliberadamente, la parte más aburrida y más importante. Aburrida porque no "hace" nada: no responde al usuario, no cambia sola. Importante porque es el andamiaje sobre el que se apoya todo lo demás. No puedes conectar un onAddToCart a un ProductCard que no existe, ni derivar una lista para un ProductList que no está montado. La estructura va primero, y una estructura bien puesta hace que las capas siguientes sean casi triviales de agregar.
Conexión con el módulo. Es la primera capa del capstone, y solo usa los dos primeros módulos de la guía. La descomposición en seis componentes con responsabilidad única es el módulo 1; la composición (un ProductList que renderiza muchos ProductCard, un Cart que renderiza muchos CartItem) también es M1. Describir cada pieza con JSX —atributos como className y htmlFor, el condicional && del badge "Sold out", el ternario del stock, el .map() con key de las listas— es el módulo 2. Es, de hecho, el mini-proyecto del módulo 2 (el catálogo) más el carrito: la mitad de abajo que entonces no teníamos con qué manejar. Aquí la montamos entera, estática, para tenerla lista cuando lleguen el estado (lección 4) y los eventos (lección 6).
Una analogía: la casa modelo sin amueblar
Cuando una constructora termina la estructura de una casa pero aún no la entrega, hace una casa modelo: las paredes están, los cuartos están definidos, la cocina tiene su lugar y el baño el suyo. Puedes caminarla y entender perfectamente cómo será la casa —dónde va la sala, cuántas recámaras, por dónde se conecta todo—. Lo único que no hay todavía es vida: no hay luz encendida, no corre el agua, no hay muebles. Es la casa descrita en firme, pero inerte.
El andamiaje del storefront es esa casa modelo. El árbol de componentes está completo: la SearchBar tiene su lugar arriba, el ProductList en el medio con sus ProductCard, el Cart abajo con sus CartItem. Puedes "caminarlo" —leer el HTML que produce— y entender exactamente cómo será la tienda. Lo que no hay todavía es interacción: el input no responde, los botones no hacen nada, la lista no se filtra. Le pasamos datos a mano (unos productos, un carrito de ejemplo) como quien pone muebles de utilería para la foto. La casa está descrita y en pie; la vida —la corriente del estado, los interruptores de los eventos— la instalamos en las próximas lecciones.
Y hay algo valioso en construir así. Una casa modelo se puede revisar antes de instalar nada: caminas y ves si un cuarto quedó mal, si falta una puerta, si la cocina está en el lugar raro. Igual el andamiaje: lo renderizamos y verificamos que el árbol entero produce el HTML correcto —cada componente en su sitio, los datos bien puestos— antes de complicarlo con estado y eventos. Estructura primero, revisada; vida después.
Ejemplo trabajado: el storefront completo, montado
Vamos a escribir los seis componentes y a componerlos en el App. Primero, en JSX real —la API que escribirás en React—. Léelo de abajo hacia arriba: primero las hojas (ProductCard, CartItem), luego los contenedores (ProductList, Cart, SearchBar), y al final el App que los compone.
function SearchBar({ query }) {
return (
<div className="search-bar">
<label htmlFor="product-search">Search products</label>
<input
id="product-search"
type="search"
className="search-input"
placeholder="Search products..."
value={query}
/>
</div>
);
}
function ProductCard({ product }) {
return (
<article className="product-card">
<h3 className="product-name">{product.name}</h3>
<p className="product-price">{formatPrice(product.priceCents)}</p>
<span className="product-stock">
{product.inStock ? 'In stock' : 'Out of stock'}
</span>
{!product.inStock && <span className="badge badge-out">Sold out</span>}
<button className="add-btn" disabled={!product.inStock}>Add to cart</button>
</article>
);
}
function ProductList({ products }) {
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} />
))}
</section>
);
}
function CartItem({ line }) {
return (
<li className="cart-item">
<span className="cart-item-name">{line.name}</span>
<span className="cart-item-qty">{'x' + line.qty}</span>
<span className="cart-item-line">{formatPrice(line.priceCents * line.qty)}</span>
<button className="remove-btn">Remove</button>
</li>
);
}
function Cart({ items }) {
return (
<aside className="cart">
<h2 className="cart-title">Your cart</h2>
{items.length === 0 ? (
<p className="empty-state">Your cart is empty</p>
) : (
<ul className="cart-items">
{items.map((line) => (
<CartItem key={line.id} line={line} />
))}
</ul>
)}
<p className="cart-total">Total: {formatPrice(cartTotal(items))}</p>
</aside>
);
}
function App({ products, query, cart }) {
// Por ahora el App solo COMPONE el arbol y baja los datos por props.
// Todavia no tiene estado ni eventos: eso son las lecciones 3-6.
return (
<main className="storefront">
<SearchBar query={query} />
<ProductList products={products} />
<Cart items={cart} />
</main>
);
}
Reconoce las herramientas de M1-M2 en su lugar. La composición (M1): el App renderiza SearchBar, ProductList y Cart; el ProductList renderiza ProductCard por producto; el Cart renderiza CartItem por línea. Los atributos (M2): className, htmlFor que apunta al id del input, value como expresión, disabled como atributo booleano. Los condicionales (M2): el ternario del stock, el && del badge "Sold out", el early return de "sin resultados" en ProductList, y el ternario del "carrito vacío" en Cart. Las listas (M2): dos .map(), cada uno con su key={id} estable. Y las props que bajan (M1): products, query, cart entran por el App y bajan a cada hijo.
Ahora la versión ejecutable en Node. Como React del navegador no corre aquí, usamos el mini renderToString de la guía (el mismo de los módulos 1 y 2, con soporte para elementos void y atributos booleanos) y escribimos cada componente con la función h(tag, props, ...children) en vez de JSX. Es la misma estructura, solo que expresada como llamadas a función para poder ejecutarla:
// --- mini render-a-string (con elementos void y atributos booleanos) ---
function h(tag, props, ...children) {
return { tag, props: props || {}, children: children.flat() };
}
const VOID = new Set(['input', 'img', 'br', 'hr']);
const ATTR = { className: 'class', htmlFor: 'for' };
function renderToString(node, indent = 0) {
const pad = ' '.repeat(indent);
if (node == null || typeof node === 'boolean') return '';
if (typeof node !== 'object') return pad + node;
const attrs = Object.entries(node.props)
.filter(([, v]) => v !== false && v != null)
.map(([k, v]) => (v === true ? ` ${ATTR[k] || k}` : ` ${ATTR[k] || k}="${v}"`))
.join('');
if (VOID.has(node.tag)) return `${pad}<${node.tag}${attrs} />`;
const kids = node.children.filter((c) => c != null && c !== false);
if (kids.length <= 1 && kids.every((c) => typeof c !== 'object'))
return `${pad}<${node.tag}${attrs}>${kids[0] ?? ''}</${node.tag}>`;
const inner = kids.map((c) => renderToString(c, indent + 1)).join('\n');
return `${pad}<${node.tag}${attrs}>\n${inner}\n${pad}</${node.tag}>`;
}
function formatPrice(cents) {
return '$' + (cents / 100).toFixed(2);
}
function cartTotal(items) {
return items.reduce((sum, line) => sum + line.priceCents * line.qty, 0);
}
// --- Los seis componentes (misma estructura que el JSX de arriba) ---
function SearchBar(props) {
return h('div', { className: 'search-bar' },
h('label', { htmlFor: 'product-search' }, 'Search products'),
h('input', {
id: 'product-search',
type: 'search',
className: 'search-input',
placeholder: 'Search products...',
value: props.query,
})
);
}
function ProductCard(props) {
const p = props.product;
return h('article', { className: 'product-card' },
h('h3', { className: 'product-name' }, p.name),
h('p', { className: 'product-price' }, formatPrice(p.priceCents)),
h('span', { className: 'product-stock' }, p.inStock ? 'In stock' : 'Out of stock'),
!p.inStock && h('span', { className: 'badge badge-out' }, 'Sold out'),
h('button', { className: 'add-btn', disabled: !p.inStock }, 'Add to cart')
);
}
function ProductList(props) {
if (props.products.length === 0) {
return h('p', { className: 'empty-state' }, 'No products match your search');
}
return h('section', { className: 'product-list' },
props.products.map((p) => ProductCard({ product: p }))
);
}
function CartItem(props) {
const line = props.line;
return h('li', { className: 'cart-item' },
h('span', { className: 'cart-item-name' }, line.name),
h('span', { className: 'cart-item-qty' }, 'x' + line.qty),
h('span', { className: 'cart-item-line' }, formatPrice(line.priceCents * line.qty)),
h('button', { className: 'remove-btn' }, 'Remove')
);
}
function Cart(props) {
const items = props.items;
const body = items.length === 0
? h('p', { className: 'empty-state' }, 'Your cart is empty')
: h('ul', { className: 'cart-items' }, items.map((line) => CartItem({ line })));
return h('aside', { className: 'cart' },
h('h2', { className: 'cart-title' }, 'Your cart'),
body,
h('p', { className: 'cart-total' }, 'Total: ' + formatPrice(cartTotal(items)))
);
}
function App(props) {
return h('main', { className: 'storefront' },
SearchBar({ query: props.query }),
ProductList({ products: props.products }),
Cart({ items: props.cart })
);
}
// Datos fijos (utileria para la foto): el catalogo y un carrito de ejemplo.
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 },
];
const cart = [
{ id: 'p1', name: 'Wireless Mouse', priceCents: 2599, qty: 1 },
{ id: 'p3', name: 'USB-C Hub', priceCents: 3499, qty: 2 },
];
console.log('=== El storefront completo, montado y renderizado ===');
console.log(renderToString(App({ products: PRODUCTS, query: '', cart })));
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== El storefront completo, montado y renderizado ===
<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="" />
</div>
<section class="product-list">
<article class="product-card">
<h3 class="product-name">Wireless Mouse</h3>
<p class="product-price">$25.99</p>
<span class="product-stock">In stock</span>
<button class="add-btn">Add to cart</button>
</article>
<article class="product-card">
<h3 class="product-name">Mechanical Keyboard</h3>
<p class="product-price">$89.00</p>
<span class="product-stock">Out of stock</span>
<span class="badge badge-out">Sold out</span>
<button class="add-btn" disabled>Add to cart</button>
</article>
<article class="product-card">
<h3 class="product-name">USB-C Hub</h3>
<p class="product-price">$34.99</p>
<span class="product-stock">In stock</span>
<button class="add-btn">Add to cart</button>
</article>
<article class="product-card">
<h3 class="product-name">Laptop Stand</h3>
<p class="product-price">$45.00</p>
<span class="product-stock">In stock</span>
<button class="add-btn">Add to cart</button>
</article>
<article class="product-card">
<h3 class="product-name">Desk Lamp</h3>
<p class="product-price">$19.99</p>
<span class="product-stock">In stock</span>
<button class="add-btn">Add to cart</button>
</article>
</section>
<aside class="cart">
<h2 class="cart-title">Your cart</h2>
<ul class="cart-items">
<li class="cart-item">
<span class="cart-item-name">Wireless Mouse</span>
<span class="cart-item-qty">x1</span>
<span class="cart-item-line">$25.99</span>
<button class="remove-btn">Remove</button>
</li>
<li class="cart-item">
<span class="cart-item-name">USB-C Hub</span>
<span class="cart-item-qty">x2</span>
<span class="cart-item-line">$69.98</span>
<button class="remove-btn">Remove</button>
</li>
</ul>
<p class="cart-total">Total: $95.97</p>
</aside>
</main>
Recorre la salida y verifica el andamiaje entero. El <main class="storefront"> envuelve las tres zonas: la search-bar, la product-list y el cart. Ese es el App componiendo (M1). La search-bar salió con su <label for="product-search"> apuntando al id del <input ... value="" /> —el htmlFor se volvió for, el className se volvió class, y el input es un elemento void sin cierre (M2)—. La product-list tiene cinco <article>, uno por producto, en el orden del array: el .map() en acción (M2). Fíjate en el Mechanical Keyboard: como su inStock es false, muestra "Out of stock" (ternario), el <span class="badge badge-out">Sold out</span> (el &&), y su <button ... disabled> (el atributo booleano) —los otros cuatro, en stock, no tienen badge y su botón está habilitado—.
Y abajo, la novedad de esta lección respecto al mini-proyecto del módulo 2: el cart. El <aside class="cart"> tiene su título, la <ul class="cart-items"> con dos <li> (una por línea del carrito de utilería), y el total. Cada CartItem muestra el nombre, la cantidad (x1, x2), y el subtotal de la línea —$25.99 para el mouse (1 × 2599), $69.98 para el hub (2 × 3499)—. El total, $95.97, es la suma en centavos (2599 + 6998 = 9597) formateada. Ese total lo calculó cartTotal(items), no lo guardamos: es un valor derivado del carrito, un adelanto del criterio que domina el storefront.
Lo esencial: dos llamadas a funciones puras —App({...}) y renderToString(...)— con datos fijos produjeron el HTML del storefront entero, catálogo y carrito, correcto pieza por pieza. Nadie manipuló una pantalla; describimos el árbol y lo renderizamos. Esa es la foto fija, verificada. Ahora hay dónde colgar la vida.
Profundización: por qué la estructura va primero
El árbol estático es la especificación de la app. Antes de que el storefront haga nada, el árbol de componentes ya dice qué es: una barra de búsqueda, una lista de tarjetas, un carrito con líneas y total. Ese HTML estático es un contrato: cuando agregues estado y eventos, la app tendrá que seguir produciendo este mismo tipo de estructura, solo que con datos que cambian. Por eso "Thinking in React" pone construir una versión estática como su paso 2, justo antes de agregar estado: primero describes la forma; después le das vida. Saltarse el estático y arrancar con estado es como cablear una casa cuyos cuartos aún no existen.
Cada componente sigue siendo f(props). Nota que ninguno de los seis componentes tiene estado todavía: cada uno es una función pura de sus props (M1). ProductCard({ product }) con el mismo producto da siempre el mismo HTML; Cart({ items }) con las mismas líneas da siempre el mismo carrito. Esa pureza es lo que hace que el andamiaje sea tan fácil de verificar: le pasas datos, compruebas la salida, sin sorpresas. Cuando en las próximas lecciones el estado entre en escena, vivirá en el App (el contenedor); los hijos seguirán siendo funciones puras de las props que reciben. Container con estado, hijos presentacionales: el patrón del módulo 7, que aquí ya está preparado.
Los datos fijos son andamiaje temporal. El PRODUCTS y el cart que escribimos a mano son utilería: existen solo para poder renderizar y verificar. En la lección 3, products dejará de ser una constante y llegará de un efecto (la carga). En la lección 5, el cart dejará de ser una constante y vivirá en el estado del App (el reducer). Es normal —y bueno— empezar con datos falsos: te dejan probar la estructura sin depender todavía de la carga ni del estado. El andamiaje se apoya en utilería; luego la utilería se reemplaza por lo real.
El App es hoy un simple compositor. Míralo: App({ products, query, cart }) no hace más que repartir sus props a tres hijos y componerlos. No decide nada, no calcula nada, no recuerda nada. Esa simplicidad es a propósito: en esta capa el App es puro andamiaje. En las capas siguientes se irá llenando de responsabilidad —el useState del query, el useReducer del cart, el useEffect de la carga, los callbacks—, pero su rol esencial (ser la raíz que compone y reparte) no cambiará. Verás crecer al App lección por lección, siempre alrededor de este esqueleto.
Errores comunes
Querer meterle estado al andamiaje "de una vez". Qué pasa: al montar los componentes, la tentación es agregar ya el useState del query o el cart, "para no volver". El resultado es que la estructura y el comportamiento se mezclan, y si algo falla no sabes si es el árbol o el estado. Por qué pasa: parece eficiente hacer todo junto. Cómo detectarlo: tu App tiene hooks antes de que hayas verificado que el árbol estático renderiza bien. Cómo corregirlo: una capa a la vez. Monta y verifica el árbol con datos fijos; recién entonces agrega la carga, y verifica; después la búsqueda, y verifica. Cada capa probada antes de la siguiente. Es lento a propósito, y es lo que hace que el capstone no se vuelva un enredo.
Poner la key en el lugar equivocado (o no ponerla). Qué pasa: al escribir los dos .map(), la key se omite o se pone en un elemento interno (el <article> en vez del <ProductCard>). React avisa "unique key prop". Por qué pasa: la key es fácil de olvidar o de traspapelar al componer. Cómo detectarlo: la advertencia en consola, o —peor, más adelante— estado local que salta al reordenar. Cómo corregirlo: la key va en el elemento que el .map() devuelve directamente —<ProductCard key={product.id} ... /> y <CartItem key={line.id} ... />—, y usa el id, no el índice. Lo veremos a fondo en la lección 7, pero ponla bien desde ya.
Confundir el subtotal de la línea con el total del carrito. Qué pasa: en CartItem se muestra formatPrice(line.priceCents) (el precio unitario) en lugar de formatPrice(line.priceCents * line.qty) (el subtotal de la línea), y el carrito no cuadra cuando hay cantidades mayores a 1. Por qué pasa: se copia el patrón de ProductCard (precio unitario) sin ajustar. Cómo detectarlo: una línea con x2 muestra el precio de una sola unidad; el subtotal no coincide con el total. Cómo corregirlo: el subtotal de una línea es precio × cantidad; el total del carrito es la suma de los subtotales (cartTotal). En el ejemplo, el hub x2 muestra $69.98 (2 × 3499), no $34.99. Cada nivel, su cálculo.
Ejercicios
Ejercicio 1 — Agrega el contador de resultados. Extiende el App (o el ProductList) para mostrar, encima de la lista, un <p className="result-count"> que diga cuántos productos hay: "5 products" con cinco, "1 product" (singular) con uno, y nada si la lista está vacía. Escríbelo en JSX, cuidando la trampa del 0.
Ver solución
function App({ products, query, cart }) {
const count = products.length;
return (
<main className="storefront">
<SearchBar query={query} />
{count > 0 && (
<p className="result-count">
{count} {count === 1 ? 'product' : 'products'}
</p>
)}
<ProductList products={products} />
<Cart items={cart} />
</main>
);
}
El contador usa && con una condición booleana (count > 0), no count a secas —si escribieras {count && ...} y la lista estuviera vacía, React pintaría un 0 fantasma, la trampa del módulo 2—. El singular/plural sale de un ternario. Con productos se ve "5 products" o "1 product"; con la lista vacía, el contador simplemente no existe. Nota que count es derivado (products.length), no un estado guardado.
Ejercicio 2 — Renderiza el carrito vacío. Sin correr nada, predice qué HTML produce el <aside class="cart"> si llamas App({ products: PRODUCTS, query: '', cart: [] }) (carrito vacío en vez del carrito de utilería). Escribe el fragmento del aside.
Ver solución
Con cart: [], el Cart toma la rama del carrito vacío (el ternario items.length === 0 ? ... : ...): en lugar de la <ul>, renderiza el <p class="empty-state">. El total se calcula igual sobre un array vacío → cartTotal([]) es 0 → $0.00:
<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>
El resto del storefront (search-bar y product-list) sale idéntico, porque products y query no cambiaron. Solo el Cart cambia de rama, porque su prop items pasó a estar vacía. Es UI = f(props): distinta prop items, distinta salida del Cart.
Ejercicio 3 — ¿Por qué el App no calcula nada todavía? En esta lección, el App solo compone y reparte props; no tiene estado, ni deriva la lista, ni maneja el carrito. Explica por qué está bien que sea "tonto" en esta capa, y qué responsabilidad concreta ganará en cada una de las próximas capas (efecto, búsqueda, carrito).
Ver solución
Está bien que el App sea "tonto" ahora porque esta capa construye la estructura, no el comportamiento. Un App que solo compone y reparte es la mínima raíz posible: fácil de leer, fácil de verificar (funciona pura de sus props), y sin nada que pueda fallar salvo la composición misma. Meterle responsabilidad antes de tener el árbol probado mezclaría dos problemas (¿falla la estructura o la lógica?).
En las próximas capas el App ganará, una por una:
- Lección 3 (efecto): un
useEffectque carga losproductsal montar (y unuseStatepara guardarlos, arrancando ennull).productsdejará de ser una constante. - Lección 4 (búsqueda): un
useStatepara elquery, y derivará la lista visible deproducts+queryantes de bajarla alProductList. - Lección 5-6 (carrito): un
useReducer(cartReducer, [])para elcart, y los callbacks (onAddToCart,onRemoveFromCart) que despacha a los hijos.
El App crece alrededor de este mismo esqueleto: la raíz que compone y reparte no cambia; lo que cambia es cuánto estado y lógica alberga. Por eso el andamiaje va primero.
Resumen y siguiente paso
En esta lección montaste el andamiaje del storefront: los seis componentes (App, SearchBar, ProductList, ProductCard, Cart, CartItem) compuestos en un árbol (M1) y descritos con JSX —atributos, condicionales, listas con key— (M2), alimentados con datos fijos que bajan por props. Lo anclaste con la casa modelo sin amueblar: la estructura en pie, "caminable" y revisable, pero todavía sin vida. Y lo ejecutaste en Node: dos funciones puras produjeron el HTML del storefront entero, catálogo y carrito, verificado pieza por pieza —las cinco tarjetas con su badge y su botón disabled, las dos líneas del carrito con sus subtotales, y el total derivado—.
Antes de avanzar deberías poder: escribir los seis componentes componiéndolos en el App; poner la key={id} en cada .map(); distinguir el subtotal de una línea (precio × cantidad) del total del carrito (la suma); y explicar por qué la estructura estática va antes que el estado y los eventos.
La lección 3 le conecta la primera pieza de vida: la carga de datos. Hasta ahora los products son una constante escrita a mano; con un useEffect (módulo 6), el App los pedirá al montarse —el estado products arrancará en null (Loading...) hasta que llegue la respuesta del servidor simulado—, con su cleanup para protegerse si el componente se desmonta. Ejecutarás la secuencia render → commit → efecto → respuesta → re-render con un mini-runtime, y verás la carga funcionar paso a paso. El andamiaje deja de tener datos de utilería y empieza a traer los suyos.
Recursos
- React, "Thinking in React" — react.dev/learn/thinking-in-react. Su paso 2 es construir una versión estática de la UI (sin estado) a partir del árbol de componentes —exactamente esta lección—. Lectura obligada del capstone. En inglés.
- React, "Describing the UI" — react.dev/learn/describing-the-ui. El repaso completo de componentes, JSX, props, condicionales y listas —todo lo que el andamiaje integra—. En inglés.
- React, "Passing Props to a Component" — react.dev/learn/passing-props-to-a-component. Cómo los datos bajan de padre a hijo por props, el flujo que arma el árbol del storefront. En inglés.
- MDN, "Void element" — developer.mozilla.org/en-US/docs/Glossary/Void_element. Qué son los elementos void (
input,img) y por qué no llevan cierre; el detalle querenderToStringmaneja para el<input />de laSearchBar. En inglés.