Módulo 1: Thinking In Components
Componer: componentes dentro de componentes
Descripción
Ya tienes piezas bien delimitadas: SearchBar, ProductCard, CartItem, cada una autocontenida. Pero una tienda no es una pila de piezas sueltas; es un ensamble. Esta lección enseña el mecanismo que las une: la composición —un componente que renderiza otros componentes por dentro—.
La composición es la idea que convierte "muchas funciones pequeñas" en "una app". Si un componente es una función, componer es llamar una función dentro de otra: ProductList no dibuja las tarjetas por su cuenta, sino que llama a ProductCard una vez por cada producto y usa lo que devuelve. El resultado es un árbol de componentes anidados —tal como el que dibujaste en la lección 4— hecho realidad: ProductList contiene ProductCard, que contiene un article, que contiene un h3... piezas dentro de piezas dentro de piezas, hasta llegar al texto.
Vas a ver esto ejecutado: un ProductList que recibe una lista de productos y produce, por cada uno, un ProductCard, todos anidados dentro de una sección. Leerás el HTML resultante y verás cómo el árbol de componentes se refleja en el HTML anidado. Y notarás, de paso, el map que aparece para recorrer la lista —un adelanto de las listas que el módulo 2 profundiza (incluido por qué necesitan una key)—.
Conexión con el módulo. La composición es el mecanismo central del modelo de componentes. Sin ella, tendrías componentes aislados que no se pueden combinar, y UI = f(props) se quedaría en tarjetas individuales. Con ella, App compone SearchBar + ProductList + Cart; ProductList compone muchos ProductCard; Cart compone muchos CartItem. Es lo que hace posible el capstone (lección 8), donde compones el storefront entero. Y prepara el terreno para el flujo de props hacia abajo (lección 6): cuando ProductList renderiza un ProductCard, le pasa los datos del producto por props, y ahí empieza el flujo de arriba hacia abajo que gobierna toda la app.
Una analogía: la receta compuesta de sub-recetas
Volvamos a la cocina. Una receta compleja rara vez está escrita como una sola lista interminable de pasos; está compuesta de sub-recetas. La receta de "lasaña" dice: "prepara la salsa boloñesa (ver receta), prepara la bechamel (ver receta), y monta las capas". La boloñesa es una receta por derecho propio —tiene sus ingredientes y sus pasos— y la lasaña simplemente la invoca, sin repetir sus detalles. La bechamel, igual.
Componer componentes es escribir recetas que invocan sub-recetas. ProductList es la receta de "la lista": no explica cómo se ve una tarjeta —eso es la sub-receta ProductCard—; solo dice "por cada producto, prepara un ProductCard". App es la receta de "la tienda": "prepara un SearchBar, prepara un ProductList, prepara un Cart, y ponlos en orden". Cada receta invoca a las de abajo y no se mete en sus detalles.
Fíjate en dos ventajas que la composición hereda de las sub-recetas. Una: la receta de la lasaña es corta y legible, porque delega los detalles. Si tuvieras que escribir la boloñesa completa dentro de la lasaña, y la bechamel completa, la receta sería un muro ilegible. Delegar a sub-recetas la mantiene entendible. Dos: la misma sub-receta de boloñesa la usas en la lasaña, en los espaguetis y en la pasta al horno —la escribes una vez y la invocas donde haga falta—. Igual que ProductCard se invoca en la lista, en los recomendados y en la portada.
El giro: componer no es copiar los pasos de la sub-receta dentro de la receta grande; es invocarla por su nombre y confiar en que hace su trabajo. ProductList no copia el marcado de la tarjeta: llama a ProductCard y usa lo que devuelve. Guarda la receta compuesta; es la composición en una imagen.
Ejemplo trabajado: ProductList compone ProductCard
Vamos a ver la composición ejecutada. ProductList recibe una lista de productos y, por cada uno, renderiza un ProductCard. El HTML resultante mostrará el anidamiento: una sección que contiene varias tarjetas.
Primero, los dos componentes en JSX (la API real). Observa la clave: dentro de ProductList, la expresión {props.products.map(...)} recorre la lista y produce un <ProductCard /> por producto. Un componente usando otro:
function ProductCard(props) {
return (
<article className="product-card">
<h3 className="product-name">{props.name}</h3>
<p className="product-price">{formatPrice(props.priceCents)}</p>
<span className="product-stock">{props.inStock ? 'In stock' : 'Out of stock'}</span>
</article>
);
}
function ProductList(props) {
return (
<section className="product-list">
{props.products.map((product) => (
<ProductCard key={product.id} name={product.name}
priceCents={product.priceCents} inStock={product.inStock} />
))}
</section>
);
}
Dos cosas para notar en ese JSX, sin profundizar (son del módulo 2). Una: {props.products.map(...)} es JavaScript de verdad dentro de las llaves —recorrer una lista y producir un componente por elemento es un patrón que verás mil veces—. Dos: el key={product.id}. Cuando renderizas una lista de componentes, React pide una key única por elemento para poder identificarlos entre re-renderizados. Aquí usamos el id del producto. Por qué la key importa a fondo, y por qué el índice de la lista es una mala key, es tema del módulo 2; por ahora solo déjala ahí, correcta.
Ahora la versión ejecutable en Node. En h, ProductList compone ProductCard llamándolo dentro del map —exactamente como el JSX, pero explícito—:
function h(tag, props, ...children) {
return { tag, props: props || {}, children: children.flat() };
}
const VOID = new Set(['input', 'img', 'br', 'hr']);
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)
.map(([k, v]) => ` ${k === 'className' ? 'class' : 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 ProductCard(props) {
return h('article', { className: 'product-card' },
h('h3', { className: 'product-name' }, props.name),
h('p', { className: 'product-price' }, formatPrice(props.priceCents)),
h('span', { className: 'product-stock' },
props.inStock ? 'In stock' : 'Out of stock')
);
}
// ProductList COMPONE: por cada producto, renderiza un ProductCard.
function ProductList(props) {
return h('section', { className: 'product-list' },
props.products.map((product) => ProductCard(product))
);
}
const products = [
{ id: 'p1', name: 'Wireless Mouse', priceCents: 2599, inStock: true },
{ id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900, inStock: false },
{ id: 'p3', name: 'USB-C Hub', priceCents: 3499, inStock: true },
];
console.log(renderToString(ProductList({ products })));
Qué esperar. Al correr el archivo, la salida es exactamente esta:
<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>
</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>
</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>
</article>
</section>
Esta salida es la composición hecha visible. Léela de afuera hacia adentro.
Lo más externo es la <section class="product-list">: es el árbol que ProductList construyó. Pero ProductList no escribió ninguna tarjeta. Mira su código: solo creó la section y, dentro, puso el resultado de llamar a ProductCard una vez por producto. Los tres <article class="product-card"> que ves anidados no los dibujó ProductList: los dibujó ProductCard, tres veces, y ProductList simplemente los acomodó dentro de su sección. Esa es la esencia de componer: la pieza grande delega los detalles a las pequeñas y solo las organiza.
Fíjate en la correspondencia entre el árbol de componentes y el HTML anidado. En el árbol, ProductList → ProductCard (tres veces). En el HTML, <section> contiene tres <article>. El anidamiento del HTML es el anidamiento de los componentes, un nivel por cada composición. Y dentro de cada <article> están el <h3>, el <p> y el <span> que ProductCard produce. Componer apiló los árboles: el de ProductList contiene los de los ProductCard, que contienen los de sus elementos, hasta el texto.
Y nota la potencia de esto: ProductList funciona para tres productos, pero funcionaría igual para trescientos o para cero. No hay nada "cableado" a tres; hay un map sobre props.products. Le das más productos, produce más tarjetas; le das una lista vacía, produce una sección vacía. La composición sobre una lista es lo que te permite describir "cualquier cantidad de tarjetas" con una sola línea. Esa es la diferencia entre una interfaz que escala y una donde tendrías que escribir cada tarjeta a mano.
El árbol de componentes, de nuevo
Vale la pena ver el árbol que acabamos de ejecutar, ahora que lo entiendes como composición:
flowchart TD
PL["ProductList"] --> PC1["ProductCard (p1)"]
PL --> PC2["ProductCard (p2)"]
PL --> PC3["ProductCard (p3)"]
PC1 --> A1["article > h3 + p + span"]
PC2 --> A2["article > h3 + p + span"]
PC3 --> A3["article > h3 + p + span"]
ProductList en la raíz; tres ProductCard colgando de él (uno por producto); y bajo cada ProductCard, los elementos que produce. Cuando renderToString recorre este árbol, va escribiendo el HTML anidado que viste: la <section> primero, luego cada <article> con su contenido. El árbol de componentes y el HTML son la misma estructura vista de dos maneras.
Errores comunes
Copiar el marcado en lugar de componer. Qué pasa: en vez de llamar a ProductCard dentro de ProductList, se copia y pega el marcado de la tarjeta (article con h3, p, span) dentro del map. Por qué pasa: funciona igual a primera vista, y uno no ve la necesidad de "invocar" la pieza. Cómo detectarlo: el marcado de la tarjeta aparece escrito en dos lugares (dentro de ProductList y donde sea que también se muestre una tarjeta); si cambias la tarjeta, tienes que cambiarla en todos. Cómo corregirlo: extrae la tarjeta a ProductCard una vez y compón —llámalo— donde la necesites. La composición es invocar la sub-receta por su nombre, no copiar sus pasos. Copiar el marcado es duplicación: el mismo bug que descomponer venía a resolver.
Olvidar la key al componer una lista. Qué pasa: al renderizar props.products.map(p => <ProductCard ... />) en React, no se pone key. Por qué pasa: la lista funciona casi bien sin ella, así que es fácil no notarlo. Cómo detectarlo: React imprime una advertencia en consola ("Each child in a list should have a unique key prop") y, en apps con estado, aparecen comportamientos raros al reordenar o filtrar. Cómo corregirlo: dale a cada elemento una key única y estable —el id del producto, no su posición en la lista—. Aquí, con todo estático, la key no cambia nada visible (por eso en la versión de Node la omitimos), pero en React real es obligatoria y el módulo 2 explica a fondo por qué el índice es una mala elección. Acostúmbrate a ponerla desde ya.
Confundir "componer" con "anidar HTML". Qué pasa: alguien cree que componer es solo meter etiquetas HTML unas dentro de otras. Por qué pasa: el resultado —HTML anidado— se parece. Cómo detectarlo: no distingues entre poner un <span> dentro de un <article> (anidar elementos) y poner un <ProductCard /> dentro de <ProductList /> (componer componentes). Cómo corregirlo: componer es específicamente un componente usando otro componente —una función tuya llamando a otra función tuya—, lo cual te da reuso, aislamiento y responsabilidad única. Anidar elementos HTML (article > h3) es solo estructura de marcado. Ambos producen anidamiento, pero solo la composición de componentes te da las ventajas del modelo. Cuando ProductList renderiza ProductCard, eso es composición; cuando ProductCard pone un h3 dentro de un article, eso es solo estructurar su propio marcado.
Ejercicios
Ejercicio 1 — Compón el carrito. Por analogía con ProductList → ProductCard, escribe el componente Cart que compone CartItem, uno por cada línea del carrito. Usa props.items como la lista. Escribe Cart en h (o JSX), asumiendo que CartItem ya existe.
Ver solución
function CartItem(props) {
return h('li', { className: 'cart-item' },
h('span', { className: 'cart-item-name' }, props.name),
h('span', { className: 'cart-item-qty' }, 'x' + props.quantity)
);
}
function Cart(props) {
return h('aside', { className: 'cart' },
h('h2', {}, 'Your cart'),
h('ul', { className: 'cart-items' },
props.items.map((item) => CartItem(item))
)
);
}
Es la misma forma que ProductList: Cart no dibuja las líneas por dentro, sino que compone CartItem una vez por cada elemento de props.items, dentro de un <ul>. Nota que aquí Cart además pone un título (<h2>Your cart</h2>) antes de la lista: un componente puede componer sub-componentes y tener marcado propio. En JSX sería {props.items.map(item => <CartItem key={item.id} name={item.name} quantity={item.quantity} />)}, con su key.
Ejercicio 2 — Predice el HTML anidado. Sin correr nada, escribe el HTML que produciría Cart (el del ejercicio 1) para props.items = [{ id: 'p1', name: 'Wireless Mouse', quantity: 1 }, { id: 'p3', name: 'USB-C Hub', quantity: 2 }].
Ver solución
<aside class="cart">
<h2>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>
</li>
<li class="cart-item">
<span class="cart-item-name">USB-C Hub</span>
<span class="cart-item-qty">x2</span>
</li>
</ul>
</aside>
El anidamiento sigue la composición: <aside> (de Cart) contiene el <h2> y el <ul>; el <ul> contiene dos <li> (de los dos CartItem); cada <li> contiene sus dos <span>. Dos productos en la entrada → dos <li> en la salida. Si la lista tuviera cinco items, habría cinco <li>; si estuviera vacía, el <ul> no tendría hijos.
Ejercicio 3 — ¿Quién dibujó cada cosa? En la salida del ejemplo trabajado (la <section> con tres <article>), identifica qué componente es responsable de producir cada parte: (a) la etiqueta <section class="product-list">; (b) cada <article class="product-card">; (c) el texto $89.00 dentro de la segunda tarjeta. Explica por qué esa asignación de responsabilidades es justamente lo que da la composición.
Ver solución
- (a)
<section class="product-list">la produjoProductList: es el elemento raíz de lo que ese componente devuelve. - (b) Cada
<article class="product-card">lo produjoProductCard, invocado una vez por producto.ProductListno escribió losarticle; solo los acomodó dentro de susection. - (c) El texto
$89.00lo produjoProductCard(el de la segunda llamada, con el teclado), al evaluarformatPrice(props.priceCents)conpriceCents: 8900. La transformación de centavos a$89.00vive dentro deProductCard, no deProductList.
Esa repartición es exactamente lo que da la composición: cada componente es responsable de su propia porción. ProductList no sabe ni le importa cómo se ve una tarjeta por dentro —eso es asunto de ProductCard—; solo sabe "por cada producto, un ProductCard". Si mañana cambias cómo se ve una tarjeta, cambias ProductCard y ProductList ni se entera. Cada quien es dueño de su pedazo, y componer es solo ensamblar esos pedazos. Esa separación de responsabilidades es la ventaja que anida en el HTML anidado.
Resumen y siguiente paso
En esta lección instalaste el mecanismo que une las piezas: la composición —un componente que renderiza otros componentes—. Viste, con la receta compuesta de sub-recetas, que componer es invocar una pieza por su nombre (no copiar sus pasos), y lo ejecutaste: ProductList compuso ProductCard una vez por producto y produjo el HTML anidado —una <section> con tres <article>—, donde el anidamiento del HTML refleja el anidamiento de los componentes. Entendiste que la pieza grande delega los detalles a las pequeñas y solo las organiza, que cada componente es dueño de su porción, y que componer sobre una lista con map escala a cualquier cantidad. De paso viste aparecer la key (a fondo en el módulo 2).
Antes de avanzar deberías poder: escribir un componente que componga a otro (como Cart → CartItem); predecir el HTML anidado que produce una composición; distinguir componer componentes de solo anidar HTML; y explicar por qué cada componente es responsable de su propia porción.
La lección 6 responde una pregunta que quedó abierta: cuando ProductList renderiza un ProductCard, ¿cómo le llegan a la tarjeta los datos del producto? La respuesta es las props que fluyen hacia abajo. Vas a trazar cómo los datos entran arriba (en App) y bajan por props hasta las hojas, en una sola dirección, y a comprobar —ejecutando el TypeError de una prop congelada— que las props son de solo lectura: un componente nunca muta las props que recibe.
Recursos
- React, "Your First Component" — react.dev/learn/your-first-component. En su sección de anidar y organizar componentes muestra cómo un componente usa a otros; la base de la composición. En inglés.
- React, "Rendering Lists" — react.dev/learn/rendering-lists. Cómo componer una lista de componentes con
mapy por qué cada uno necesitakey. Amplía el patrón deProductList. En inglés. - React, "Passing Props to a Component" — react.dev/learn/passing-props-to-a-component. Cómo un componente le pasa datos a los que compone; el puente hacia la lección 6. En inglés.
- MDN, "Array.prototype.map()" — developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map. El método de JavaScript que usamos para producir un componente por elemento de la lista. En inglés.