Módulo 1: Thinking In Components

Descomponer el storefront en componentes

Descripción

Hasta ahora trabajamos con un componente: ProductCard. Pero una pantalla real no es una tarjeta; es una tienda entera —barra de búsqueda, lista de productos, carrito—. La pregunta de esta lección es la que enfrentas cada vez que empiezas una interfaz nueva: ¿cómo parto esta pantalla en componentes? ¿Dónde van los límites? ¿Qué es una pieza y qué es parte de otra?

Descomponer no es cortar al azar. Es un acto de diseño con criterios claros, y esta lección te los da. La idea de fondo es que cada componente debe tener una responsabilidad: una cosa que hace y de la que es dueño. ProductCard es dueño de "mostrar un producto"; SearchBar es dueño de "la barra de búsqueda"; CartItem es dueño de "una línea del carrito". Cuando una pieza empieza a hacer dos cosas —mostrar un producto y manejar el carrito y filtrar la lista—, es señal de que ahí hay dos componentes disfrazados de uno.

Vas a aprender a mirar un diseño y ver los componentes escondidos en él, guiándote por dos pistas concretas —la repetición (algo que aparece muchas veces suele ser un componente reusable) y la responsabilidad única (cada pieza, un trabajo)—, a trazar los límites, y a dibujar el árbol de componentes que resulta. Y lo comprobaremos ejecutando cada pieza por separado, para ver que un componente bien delimitado es una unidad autocontenida que produce su propio fragmento de HTML sin depender de las demás.

Conexión con el módulo. Descomponer es el puente entre "sé qué es un componente" (lecciones 2 y 3) y "sé armar una app" (lecciones 5 a 8). Una vez que tienes las piezas bien delimitadas, la composición (lección 5) las ensambla, las props (lección 6) les pasan sus datos, y las razones para descomponer (lección 7) justifican por qué te tomaste el trabajo. Este es el paso que "Thinking in React" —el ensayo canónico de la documentación oficial— pone como el primero de todos: empieza dividiendo la UI en una jerarquía de componentes. Aquí lo hacemos con el storefront de Mercado.

Una analogía: los muebles modulares

Piensa en cómo amueblas una cocina con muebles modulares (tipo los de una tienda sueca de muebles). No compras "una cocina" de una sola pieza gigante; compras módulos: un módulo de cajones, un módulo de puertas, una repisa, un entrepaño. Cada módulo hace una cosa —guardar cubiertos, colgar, sostener platos— y los combinas para armar la cocina que necesitas.

¿Por qué se venden así y no como una pieza única? Por tres razones que son, tal cual, las de descomponer una UI.

Porque se repiten. Usas el mismo módulo de cajones tres veces a lo largo de la pared. No diseñaron tres cajoneras distintas: hicieron una y la repites. En una UI, la tarjeta de producto se repite cien veces en el catálogo: no haces cien tarjetas, haces un ProductCard y lo repites.

Porque cada uno tiene un trabajo. El módulo de cajones guarda; la repisa sostiene. Si un módulo intentara guardar y sostener y colgar y además fuera el fregadero, sería un monstruo imposible de fabricar, mover o reemplazar. Cada módulo, una función. Cada componente, una responsabilidad.

Porque se reemplazan sin tocar el resto. Si el módulo de cajones se daña, lo cambias solo, sin desarmar la cocina. Si un componente tiene un bug, lo arreglas solo, sin tocar los demás.

El acto de "descomponer" es, exactamente, mirar la cocina que quieres y decidir qué módulos la componen y dónde va cada uno. Mirar el storefront de Mercado y decidir sus componentes es el mismo acto. Guarda los muebles modulares; es la descomposición en una imagen.

Cómo mirar una pantalla y ver sus componentes

Volvamos al diseño del storefront de Mercado y hagamos el ejercicio de descomponerlo, en voz alta, con criterios.

┌──────────────────────────────────────────────┐
│  [ Search products...              ]          │  <- 1
├──────────────────────────────────────────────┤
│  ┌────────────┐ ┌────────────┐ ┌────────────┐ │  <- 2 (contiene 3)
│  │Wireless    │ │Mechanical  │ │USB-C Hub   │ │
│  │Mouse       │ │Keyboard    │ │            │ │  <- 3 (se repite)
│  │$25.99      │ │$89.00      │ │$34.99      │ │
│  │In stock    │ │Out of stock│ │In stock    │ │
│  └────────────┘ └────────────┘ └────────────┘ │
├──────────────────────────────────────────────┤
│  Your cart                                     │  <- 4 (contiene 5)
│   • Wireless Mouse   x1                        │  <- 5 (se repite)
│   • USB-C Hub        x2                        │
└──────────────────────────────────────────────┘

Aplicamos dos pistas. Pista 1: lo que se repite es un componente. Mira las tarjetas de producto: son la misma estructura (nombre, precio, stock) repetida tres veces con datos distintos. Eso grita "componente reusable": es ProductCard. Lo mismo con las líneas del carrito (nombre + cantidad, repetido): es CartItem. Pista 2: cada zona con una responsabilidad clara es un componente. La barra de búsqueda hace una cosa (buscar): es SearchBar. La zona que agrupa todas las tarjetas hace una cosa (listar productos): es ProductList. La zona del carrito hace una cosa (mostrar el carrito): es Cart. Y algo tiene que contener a los tres: es App.

Fíjate en la jerarquía de contención, que es lo que forma el árbol. ProductList contiene los ProductCard (la zona 2 contiene las zonas 3). Cart contiene los CartItem (la zona 4 contiene las zonas 5). Y App contiene a SearchBar, ProductList y Cart. Esa relación "X contiene a Y" es exactamente la relación padre-hijo del árbol de componentes:

flowchart TD
    App[App] --> SearchBar[SearchBar]
    App --> ProductList[ProductList]
    App --> Cart[Cart]
    ProductList --> ProductCard[ProductCard]
    Cart --> CartItem[CartItem]

(Dibujamos un solo ProductCard y un solo CartItem para no repetir; en la práctica hay uno por cada producto y por cada línea del carrito, como vimos en la lección 1.)

Nota una decisión de diseño: podríamos haber hecho ProductList una sola pieza gigante que dibujara todas las tarjetas por dentro, sin ProductCard. Sería un error. Al separar ProductCard, ganamos una pieza reusable (la tarjeta), con una responsabilidad clara (mostrar un producto), que podemos probar sola y usar en otros lados (la lección 7 lo aprovecha). La regla: cuando veas una repetición o una responsabilidad separable, sácala a su propio componente.

Ejemplo trabajado: cada pieza, por separado

Vamos a comprobar la propiedad más valiosa de una buena descomposición: cada componente es una unidad autocontenida. Si están bien delimitados, puedo tomar SearchBar, ProductCard o CartItem y renderizar cada uno solo, sin los demás, y cada uno produce su propio fragmento de HTML correcto. Esa independencia es la prueba de que los límites están bien puestos.

Primero, los tres componentes en JSX (la API real):

function SearchBar(props) {
  return (
    <form className="search-bar">
      <input className="search-input" value={props.query} placeholder="Search products" />
    </form>
  );
}

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 CartItem(props) {
  return (
    <li className="cart-item">
      <span className="cart-item-name">{props.name}</span>
      <span className="cart-item-qty">{'x' + props.quantity}</span>
    </li>
  );
}

Aquí aparece algo nuevo: el <input> de SearchBar. En HTML, input es un elemento void: no tiene contenido ni etiqueta de cierre (no existe </input>). Nuestro renderToString hasta ahora asumía que todo elemento se cierra, así que lo ampliamos una línea para manejar los elementos void (input, img, br, hr), que se renderizan como <input ... /> sin cierre. Es el tipo de extensión pequeña y motivada que harás en código real. Aquí está la versión ejecutable, con esa ampliación:

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; // texto
  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} />`; // input, img: sin cierre
  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);
}

// Cada pieza de la pantalla es su propio componente, y se renderiza sola.
function SearchBar(props) {
  return h('form', { className: 'search-bar' },
    h('input', { className: 'search-input', value: props.query, placeholder: 'Search products' })
  );
}
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')
  );
}
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)
  );
}

console.log('--- SearchBar sola ---');
console.log(renderToString(SearchBar({ query: 'mouse' })));
console.log('\n--- ProductCard solo ---');
console.log(renderToString(ProductCard({ name: 'USB-C Hub', priceCents: 3499, inStock: true })));
console.log('\n--- CartItem solo ---');
console.log(renderToString(CartItem({ name: 'Wireless Mouse', quantity: 2 })));

Qué esperar. Al correr el archivo, la salida es exactamente esta:

--- SearchBar sola ---
<form class="search-bar">
  <input class="search-input" value="mouse" placeholder="Search products" />
</form>

--- ProductCard solo ---
<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>

--- CartItem solo ---
<li class="cart-item">
  <span class="cart-item-name">Wireless Mouse</span>
  <span class="cart-item-qty">x2</span>
</li>

Lo importante de esta salida no es cada fragmento por sí mismo —ya sabes leer HTML—, sino que cada componente se renderizó solo, sin los demás. Pudimos tomar SearchBar y obtener su <form> con su <input> sin que existiera ninguna lista ni ningún carrito. Pudimos tomar ProductCard y obtener su tarjeta sin que existiera la barra de búsqueda. Pudimos tomar CartItem y obtener su <li> sin que existiera el carrito que lo contiene. Cada pieza es autocontenida: recibe sus props, produce su fragmento, y no necesita nada de las demás.

Esa independencia es la prueba de una buena descomposición, y tiene consecuencias muy concretas. Significa que puedes desarrollar ProductCard sin haber construido aún ProductList. Puedes probar CartItem con datos de mentira, aislado, sin levantar la app entera. Puedes razonar sobre SearchBar mirando solo su función, sin cargar en la cabeza todo el storefront. Si, en cambio, ProductCard no pudiera renderizarse sin el carrito —si dependiera de él—, los límites estarían mal puestos: habrías cortado por donde no era.

Fíjate también en el <input ... /> de la salida de SearchBar: gracias a la ampliación de renderToString, salió como elemento void, sin </input>. Un detalle pequeño, pero es exactamente el tipo de cosa que separa "código que se parece a HTML" de "HTML correcto".

Errores comunes

El componente gigante que hace todo. Qué pasa: en vez de descomponer, alguien escribe un solo Storefront de doscientas líneas que dibuja la barra, arma la lista producto por producto, y pinta el carrito, todo por dentro. Por qué pasa: al principio parece más rápido no "molestarse" en crear piezas. Cómo detectarlo: tu componente hace más de una cosa; para entender un pedazo tienes que leerlo entero; no puedes reusar ninguna parte porque todo está entrelazado. Cómo corregirlo: aplica la responsabilidad única. Cada zona con un trabajo propio —la búsqueda, la lista, una tarjeta, el carrito, una línea— sale a su propio componente. La señal de alarma es la conjunción "y": "este componente muestra la lista y el carrito y la búsqueda" significa que ahí hay tres componentes. Un componente que necesita "y" para describirse está haciendo de más.

Cortar por el lugar equivocado (límites que no calzan con la responsabilidad). Qué pasa: se crean componentes que no corresponden a una responsabilidad real, como TopHalf y BottomHalf, partiendo la pantalla por geometría en vez de por función. Por qué pasa: se confunde "dividir el espacio visual" con "dividir responsabilidades". Cómo detectarlo: un componente agrupa cosas que no tienen nada que ver entre sí (la mitad de la búsqueda y la mitad de la lista), o una responsabilidad queda partida entre dos componentes. Cómo corregirlo: corta por responsabilidad, no por posición. La pregunta no es "¿qué está arriba y qué está abajo?", sino "¿qué cosa hace esta pieza?". SearchBar es una responsabilidad; "la mitad superior de la pantalla" no lo es.

Sobre-descomponer: una pieza para cada cosita. Qué pasa: en el extremo opuesto, alguien crea ProductName, ProductPrice, ProductStockLabel como componentes separados, cuando son solo tres líneas dentro de ProductCard. Por qué pasa: se aplica "descompón" sin medida. Cómo detectarlo: tienes componentes de una sola línea que solo se usan en un lugar y no aportan reuso ni claridad; saltas entre veinte archivos para entender una tarjeta. Cómo corregirlo: descompón cuando hay repetición (se usa en varios lados) o cuando una responsabilidad es lo bastante grande como para merecer su propio espacio. Tres líneas que siempre van juntas y solo aquí no necesitan ser tres componentes; están bien como parte de ProductCard. El criterio es el equilibrio: ni un monstruo que hace todo, ni un enjambre de piezas triviales.

Ejercicios

Ejercicio 1 — Descompón una pantalla nueva. Mercado agrega una página de "perfil del vendedor" con: un encabezado (foto, nombre del vendedor, calificación), una lista de sus productos (cada uno con nombre y precio), y una sección de reseñas (cada reseña con autor y texto). Propón los componentes en los que la descompondrías y dibuja su árbol. Justifica cada componente con una de las dos pistas (repetición o responsabilidad).

Ver solución

Una descomposición razonable:

SellerProfile
├── SellerHeader        (responsabilidad: mostrar la identidad del vendedor)
├── SellerProductList   (responsabilidad: listar los productos del vendedor)
│   └── ProductCard     (repeticion: una tarjeta por producto  -> reusable)
└── ReviewList          (responsabilidad: mostrar las resenas)
    └── ReviewCard      (repeticion: una tarjeta por resena)

Justificación:

  • SellerHeader: responsabilidad única (la identidad del vendedor: foto, nombre, calificación). No se repite, pero es una zona con un trabajo claro.
  • SellerProductList y ReviewList: cada una tiene una responsabilidad (listar productos / listar reseñas) y contiene piezas repetidas.
  • ProductCard (¡el mismo de Mercado, reusado!) y ReviewCard: repetición —una tarjeta por producto, una por reseña—, así que salen a su propio componente.
  • SellerProfile: el contenedor raíz, análogo a App.

Lo valioso: pudiste reusar ProductCard, que ya existe, para los productos del vendedor. Esa es la recompensa de haberlo separado antes. No hay una única respuesta "correcta", pero cualquier buena descomposición corta por responsabilidad y saca las repeticiones a componentes reusables.

Ejercicio 2 — Detecta el componente gigante. Un compañero escribió esto. Explica qué está mal desde el punto de vista de la descomposición y reescríbelo partiéndolo en componentes con responsabilidad única (puedes escribir solo la estructura, con h o JSX):

function Storefront(props) {
  return h('div', {},
    h('form', {}, h('input', { value: props.query })),
    h('section', {},
      props.products.map((p) =>
        h('article', {}, h('h3', {}, p.name), h('p', {}, formatPrice(p.priceCents)))
      )
    )
  );
}
Ver solución

Qué está mal: Storefront hace tres trabajos a la vez —la barra de búsqueda, la lista, y la tarjeta de cada producto— todo entrelazado en una sola función. La estructura de la tarjeta (article con h3 y p) está escrita dentro del map, así que no se puede reusar ni probar por separado; y la barra de búsqueda está mezclada con la lista. Es el componente gigante del primer error común.

Reescrito, partido por responsabilidad:

function SearchBar(props) {
  return h('form', { className: 'search-bar' },
    h('input', { className: 'search-input', value: props.query })
  );
}
function ProductCard(props) {
  return h('article', { className: 'product-card' },
    h('h3', { className: 'product-name' }, props.name),
    h('p', { className: 'product-price' }, formatPrice(props.priceCents))
  );
}
function ProductList(props) {
  return h('section', { className: 'product-list' },
    props.products.map((p) => ProductCard(p))
  );
}
function Storefront(props) {
  return h('div', { className: 'storefront' },
    SearchBar({ query: props.query }),
    ProductList({ products: props.products })
  );
}

Ahora cada pieza tiene un trabajo: SearchBar (buscar), ProductCard (una tarjeta —reusable, probable sola), ProductList (la lista), y Storefront solo ensambla las tres. La estructura de la tarjeta salió del map a su propio componente, así que ahora se puede reusar. Esto último —ProductList llamando a ProductCard— es la composición, que es justo el tema de la lección 5.

Ejercicio 3 — ¿Por qué se pudo renderizar solo? En el ejemplo trabajado renderizamos CartItem completamente solo, sin Cart ni App. Explica qué propiedad de una buena descomposición hizo eso posible, y qué habría significado si CartItem no hubiera podido renderizarse sin Cart.

Ver solución

Se pudo renderizar solo porque CartItem es una unidad autocontenida: todo lo que necesita para producir su HTML le llega por sus props (name y quantity), y no depende de ningún otro componente para funcionar. Le pasamos { name: 'Wireless Mouse', quantity: 2 } y produjo su <li> completo, sin que existiera ningún Cart a su alrededor. Esa autonomía es la marca de que el límite del componente está bien puesto: la pieza tiene una responsabilidad clara y una entrada bien definida.

Si CartItem no hubiera podido renderizarse sin Cart —por ejemplo, si buscara datos "hacia arriba" en el carrito, o si dependiera de variables que solo Cart define—, sería señal de que los límites están mal: la responsabilidad de "una línea del carrito" estaría enredada con la de "el carrito", y las dos piezas estarían acopladas. Las consecuencias serían caras: no podrías probar CartItem aislado, no podrías reusarlo en otro contexto, y para entender una línea tendrías que cargar en la cabeza todo el carrito. La independencia que comprobamos no es un lujo: es lo que hace que la descomposición valga la pena.

Resumen y siguiente paso

En esta lección aprendiste a descomponer: mirar una pantalla y partirla en componentes con criterio. Viste las dos pistas —la repetición (algo que se repite es un componente reusable, como ProductCard o CartItem) y la responsabilidad única (cada pieza, un trabajo, como SearchBar o Cart)— y las aplicaste al storefront de Mercado para trazar su árbol (AppSearchBar + ProductListProductCard; AppCartCartItem). Y comprobaste, ejecutando cada pieza por separado, la propiedad que valida una buena descomposición: cada componente es autocontenido, produce su propio fragmento de HTML sin depender de los demás. De paso, ampliaste renderToString para elementos void (<input />).

Antes de avanzar deberías poder: mirar un diseño y proponer sus componentes justificando cada uno con una pista (repetición o responsabilidad); dibujar el árbol de contención; detectar el componente gigante y partirlo; y explicar por qué la independencia de cada pieza es la prueba de que los límites están bien.

La lección 5 toma esas piezas separadas y las ensambla: la composición. Vas a ver cómo ProductList no dibuja las tarjetas por dentro, sino que renderiza ProductCard por cada producto —componentes dentro de componentes—, y a ejecutar la lista completa para leer el HTML anidado que resulta. Es el mecanismo por el que las piezas pequeñas se vuelven pantallas enteras.

Recursos