Módulo 7: Lifting State And Composition
Container y presentational: separar el estado de la vista
Descripción
Este módulo te dio tres cosas: dónde vive el estado (levantado en el App), cómo cambia (un reducer manejado por useReducer), y cómo baja a los hijos (composición, sin prop drilling). Esta lección las junta en un patrón de organización que emerge naturalmente de todo lo anterior: separar los componentes que tienen el estado y la lógica de los que solo muestran. A los primeros se les llama container (o "con estado", o "smart"): el App con su useReducer, que decide qué pasa. A los segundos, presentational (o "de presentación", o "tonto", sin ánimo de ofender): componentes que reciben todo por props y solo pintan —el CartView que recibe items y onRemove y no tiene estado propio ni sabe de dónde salen los datos—. La gracia de un presentational es que es una función pura de sus props: mismas props, misma salida. Y eso —lo vas a ver ejecutado— lo vuelve testeable (se prueba con una entrada y una salida, sin navegador) y reutilizable (sirve en cualquier lado que le pase las props correctas). Es el mismo superpoder del reducer puro de la lección 4, ahora aplicado a los componentes de vista.
Conexión con el módulo. Es el cierre que ordena todo. Levantar (lecciones 2-3) puso el estado en un container (el App); el reducer (lecciones 4-5) es la lógica de ese container; componer (lección 6) es cómo el container arma y coloca a los presentational. Este patrón le da nombre al reparto que ya venías haciendo. Y remite, por última vez, el estado global a frontend-state-and-data: cuando el "container" deja de ser un componente de tu árbol y pasa a ser un store externo, eso es la otra guía.
Una analogía: el titiritero y el títere
Piensa en un titiritero con su títere. El reparto es tajante. El titiritero tiene los hilos, la voluntad y la historia: decide cuándo el títere saluda, cuándo se ríe, cuándo se va. Toda la lógica —qué hace y por qué— vive en él. El títere no decide nada: es de madera y tela, y se mueve exactamente como le tiran de los hilos. No tiene voluntad propia, no recuerda nada, no improvisa. Dale los mismos tirones de hilo y hará el mismo movimiento, siempre.
Un componente container es el titiritero: tiene el estado (el carrito), la lógica (el reducer), y decide (despacha acciones). Un componente presentational es el títere: recibe por props lo que debe mostrar (items) y los hilos para avisar (onRemove, onClear), y solo se mueve según eso. No tiene estado propio, no sabe de dónde vienen los items (¿de un reducer?, ¿de un servidor?, ¿de datos de prueba?), no le importa. Su trabajo es puramente visual: dadas estas props, se ve así.
Lo valioso de esta separación es la misma propiedad que perseguimos en todo el módulo: la pureza. Como el títere solo depende de los tirones que recibe (sus props) y no guarda nada oculto, es predecible: mismas props, misma pinta. Eso lo hace testeable —le das unas props y compruebas qué muestra, sin montar toda la obra— y reutilizable —el mismo títere sirve en otra obra, con otro titiritero, mientras le tiren de los hilos correctos—. El CartView que muestra un carrito sirve igual en la tienda, en un resumen de pedido o en una captura de prueba: cualquiera que le pase items obtiene la misma vista.
Ejemplo trabajado: un presentational probado como función pura
Vamos a separar el Cart en sus dos naturalezas y a probar la presentacional como la función pura que es. El CartView (presentational) recibe items y onRemove por props y solo arma el texto de la vista —el título, cada línea con su subtotal, y el total—; no tiene useState, no despacha nada, no sabe de reducers. El container (el App, aquí resumido) tiene el reducer y le pasa los items ya calculados.
Primero, cómo se ven en React de verdad. El presentational, sin estado, puro props:
// PRESENTATIONAL: solo muestra sus props. Sin estado, sin logica de negocio.
function CartView({ items, onRemove }) {
const total = items.reduce((sum, i) => sum + i.priceCents * i.qty, 0);
return (
<section className="cart">
<h2>Cart ({items.length} lineas)</h2>
<ul>
{items.map((i) => (
<li key={i.id}>
{i.name} x{i.qty} — {formatPrice(i.priceCents * i.qty)}
<button onClick={() => onRemove(i.id)}>Remove</button>
</li>
))}
</ul>
<p>TOTAL: {formatPrice(total)}</p>
</section>
);
}
// CONTAINER: tiene el estado/logica y le pasa props al presentational.
function CartContainer() {
const [cart, dispatch] = useReducer(cartReducer, []);
return (
<CartView
items={cart}
onRemove={(id) => dispatch({ type: 'remove', id })}
/>
);
}
Fíjate en el reparto: CartView no tiene useReducer ni sabe qué es una acción; recibe items y un hilo onRemove. CartContainer no tiene JSX de presentación (ni <ul>, ni <li>); tiene el estado y le entrega los datos al presentational. Titiritero y títere. Ahora ejecutemos el presentational como función pura, comprobando que mismas props dan misma salida, y luego un container que le pasa datos de un reducer:
function formatPrice(cents) { return '$' + (cents / 100).toFixed(2); }
// PRESENTACIONAL: una funcion pura de props. Mismas props -> misma salida.
function CartView(props) {
const total = props.items.reduce((s, i) => s + i.priceCents * i.qty, 0);
const lines = props.items.map(
(i) => ` ${i.name} x${i.qty} -> ${formatPrice(i.priceCents * i.qty)}`
);
return [`Cart (${props.items.length} lineas)`, ...lines, ` TOTAL: ${formatPrice(total)}`].join('\n');
}
const items = [
{ id: 'p1', name: 'Wireless Mouse', priceCents: 2599, qty: 2 },
{ id: 'p3', name: 'USB-C Hub', priceCents: 3499, qty: 1 },
];
console.log('=== Presentacional: funcion pura de props ===\n');
const out1 = CartView({ items, onRemove: () => {} });
const out2 = CartView({ items, onRemove: () => {} });
console.log(out1);
console.log(`\nMismas props dos veces -> misma salida? ${out1 === out2}`);
console.log('\n=== Contenedor: tiene el estado/logica y le pasa props al presentacional ===\n');
function cartReducer(state, action) { /* ...add / clear (igual que la leccion 4)... */ }
// El contenedor NO pinta: tiene el estado (reducer) y decide. Le pasa `items` al presentacional.
let state = [];
state = cartReducer(state, { type: 'add', product: { id: 'p1', name: 'Wireless Mouse', priceCents: 2599 } });
state = cartReducer(state, { type: 'add', product: { id: 'p1', name: 'Wireless Mouse', priceCents: 2599 } });
state = cartReducer(state, { type: 'add', product: { id: 'p3', name: 'USB-C Hub', priceCents: 3499 } });
console.log(CartView({ items: state, onRemove: () => {} }));
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== Presentacional: funcion pura de props ===
Cart (2 lineas)
Wireless Mouse x2 -> $51.98
USB-C Hub x1 -> $34.99
TOTAL: $86.97
Mismas props dos veces -> misma salida? true
=== Contenedor: tiene el estado/logica y le pasa props al presentacional ===
Cart (2 lineas)
Wireless Mouse x2 -> $51.98
USB-C Hub x1 -> $34.99
TOTAL: $86.97
Lee las dos partes, porque muestran las dos virtudes del presentational.
En la primera parte, llamamos a CartView dos veces con las mismas props y comparamos las salidas: out1 === out2 es true. Esa igualdad es la prueba de pureza: el CartView no depende de nada más que sus props —no tiene estado oculto, no lee la hora, no consulta nada externo—, así que mismas props producen, palabra por palabra, la misma vista. Y fíjate en lo que la vista contiene, todo derivado de las props: Wireless Mouse x2 -> $51.98 (2599×2 formateado), USB-C Hub x1 -> $34.99, y el TOTAL: $86.97 (5198 + 3499). El presentational calcula lo que muestra a partir de sus props, sin guardar nada.
En la segunda parte, un container arma el estado con el cartReducer (agregando el mouse dos veces y el hub una) y le pasa ese state como items al mismo CartView. La salida es idéntica a la primera parte, y no por casualidad: el reducer produjo exactamente el mismo items (mouse x2, hub x1), y el presentational, siendo puro, produjo la misma vista. Eso demuestra la separación: al CartView no le importa de dónde salieron los items —de datos fijos en la primera parte, de un reducer en la segunda—; con las mismas props, la misma vista. El titiritero cambió; el títere hizo el mismo movimiento porque le tiraron de los mismos hilos.
La lección en una frase: la vista es una función pura de sus props; el estado y la lógica viven aparte, en el container. Esa frontera es lo que hace la vista testeable (la pruebas con props, sin React) y reutilizable (sirve con cualquier fuente de datos).
Profundización: el patrón, sus grados y su frontera
Qué distingue a cada uno. Un container: tiene estado (useState/useReducer), tiene lógica (handlers, despachos, decisiones), y suele traer los datos (de un reducer, de props de más arriba, o —fuera de esta guía— de un servidor). Un presentational: recibe todo por props, no tiene estado propio (o solo estado de pura UI, como "hover"), no tiene lógica de negocio, y su salida es función de sus props. La prueba rápida: si le quitas todas las props, ¿el componente aún "sabe" algo por su cuenta? Si no sabe nada (todo venía de props), es presentational; si guarda o decide algo, es container.
No es blanco y negro; es un espectro. Pocos componentes son 100% container o 100% presentational, y no pasa nada. Un ProductCard puede ser mayormente presentational (muestra name, priceCents) y tener un pelín de estado local de UI (expanded, lección 3) sin dejar de ser "de vista". La distinción es una guía de diseño, no una ley: te empuja a no mezclar la lógica de negocio con el JSX de presentación, porque mezclarlas hace ambos difíciles de probar y reusar. Úsala como brújula, no como reglamento.
Por qué separar ayuda (los tres beneficios).
- Testeable. El presentational es puro: le das props, compruebas la salida, sin montar React ni simular clics. El container, con su lógica en un reducer puro (lección 4), también se prueba aislado. Cada mitad se verifica por su cuenta.
- Reutilizable. El
CartViewsirve con cualquier fuente deitems: la tienda, un resumen de pedido, una captura de prueba. No está casado con un reducer ni un servidor. - Legible. Al leer el código sabes de inmediato dónde buscar: la lógica está en los containers, la pinta en los presentational. Un bug de cálculo → mira el reducer/container; un bug visual → mira el presentational.
flowchart TD
C["CartContainer (container)<br/>useReducer + logica + decide"]
V["CartView (presentational)<br/>funcion pura de props: solo muestra"]
C -- "items, onRemove (props)" --> V
V -. "onRemove(id) (hilo)" .-> C
La frontera final: cuando el container deja de ser un componente. En este módulo, el "container" es un componente de tu árbol de React (el App, CartContainer) que guarda el estado con hooks. Pero a gran escala, el estado a veces se saca fuera del árbol —a un store global (Zustand, Redux) o se sincroniza con el servidor (React Query)—, y los componentes de vista lo leen de ahí. Ese es el mismo patrón (vista separada de la fuente del estado) llevado al siguiente nivel, y es de la guía frontend-state-and-data. La intuición que te llevas de aquí —separa lo que muestra de lo que tiene el estado— es exactamente la que necesitarás allá; solo cambia dónde vive el estado.
Errores comunes
Meter lógica de negocio en un componente de presentación. Qué pasa: el CartView, además de mostrar, calcula descuentos, decide qué productos ocultar, o llama a un servidor. Deja de ser puro: ya no basta con darle props para saber qué mostrará, y no lo puedes probar aislado. Por qué pasa: "ya que estoy pintando el carrito, aprovecho y calculo aquí". Cómo detectarlo: tu componente de vista tiene ifs de negocio, fetch, o decide cosas que no son "cómo se ve". Cómo corregirlo: sube esa lógica al container (o al reducer), y deja que el presentational reciba el resultado ya calculado por props. La vista solo pinta.
Darle estado propio a un presentational que debía recibirlo. Qué pasa: el CartView guarda su propia copia de items en un useState en vez de mostrarlos desde props, y se desincroniza del carrito real del container (el error de las lecciones 1-3, otra vez). Por qué pasa: la costumbre de guardar en estado lo que llega. Cómo detectarlo: el presentational no refleja cambios del container. Cómo corregirlo: un presentational muestra sus props directo; no las copia a estado. Si acaso guarda estado, que sea de pura UI (un "hover", un "abierto"), nunca el dato de negocio, que es del container.
Volver el patrón un dogma y partir todo en dos. Qué pasa: por cada componente creas religiosamente un XContainer y un XView, aun para cosas triviales, y terminas con el doble de archivos sin ganar nada. Por qué pasa: se toma la guía de diseño como una ley. Cómo detectarlo: tienes containers de una línea que solo reenvían props a un view, sin lógica real. Cómo corregirlo: separa cuando hay lógica que aislar o vista que reutilizar; si un componente es pequeño y no mezcla nada, déjalo entero. El objetivo es no mezclar lógica pesada con presentación, no multiplicar archivos.
Ejercicios
Ejercicio 1 — Clasifica. Para cada componente, di si es más container o más presentational, y por qué: (a) App con useReducer(cartReducer, []) que decide y pasa datos; (b) CartItem que recibe item y onRemove y solo pinta una línea con un botón; (c) ProductCard que muestra name/priceCents y tiene un useState local de expanded; (d) SearchBar controlada que recibe query y onQueryChange.
Ver solución
- (a) Container. Tiene el estado (el reducer), la lógica y decide. Casi no pinta; coordina.
- (b) Presentational. Recibe todo por props (
item,onRemove) y solo muestra. Función pura de sus props. El títere clásico. - (c) Mayormente presentational, con un toque de estado de UI. Muestra props (
name,priceCents); su único estado (expanded) es de pura UI, no de negocio. Sigue siendo "de vista". Es el espectro: no todo es blanco o negro. - (d) Presentational. Recibe
query(qué mostrar) yonQueryChange(el hilo para avisar), sin estado propio del dato (vive levantado, módulo 4). Solo muestra y avisa.
La brújula: ¿tiene estado/lógica de negocio y decide (container), o recibe todo por props y solo muestra (presentational)? El estado de pura UI no lo vuelve container.
Ejercicio 2 — Extrae el presentational. Este componente mezcla estado y presentación. Sepáralo en un container (con el reducer) y un presentational (función pura de props).
function Cart() {
const [cart, dispatch] = useReducer(cartReducer, []);
const total = cart.reduce((s, i) => s + i.priceCents * i.qty, 0);
return (
<section>
<h2>Cart ({cart.length})</h2>
{cart.map((i) => (
<div key={i.id}>{i.name} x{i.qty}
<button onClick={() => dispatch({ type: 'remove', id: i.id })}>Remove</button>
</div>
))}
<p>{formatPrice(total)}</p>
</section>
);
}
Ver solución
El presentational recibe items y onRemove, sin reducer ni estado; el container tiene el reducer y le pasa las props:
// PRESENTATIONAL: funcion pura de props. Solo muestra y avisa por onRemove.
function CartView({ items, onRemove }) {
const total = items.reduce((s, i) => s + i.priceCents * i.qty, 0);
return (
<section>
<h2>Cart ({items.length})</h2>
{items.map((i) => (
<div key={i.id}>{i.name} x{i.qty}
<button onClick={() => onRemove(i.id)}>Remove</button>
</div>
))}
<p>{formatPrice(total)}</p>
</section>
);
}
// CONTAINER: tiene el estado/logica; no pinta detalles, entrega props.
function CartContainer() {
const [cart, dispatch] = useReducer(cartReducer, []);
return <CartView items={cart} onRemove={(id) => dispatch({ type: 'remove', id })} />;
}
Ahora CartView es puro (mismas props → misma vista), testeable sin React y reutilizable con cualquier fuente de items. El botón "Remove" ya no despacha directo: invoca el hilo onRemove(id), y el container decide despachar. Titiritero (container) y títere (view).
Ejercicio 3 — Prueba el presentational sin React. Como CartView es una función pura de props, se prueba sin navegador. Escribe una pequeña comprobación (en JavaScript, estilo del ejemplo trabajado) que verifique que, dado items con un mouse x2 (2599 c/u) y un hub x1 (3499), el total mostrado es $86.97. ¿Por qué esta prueba es posible aquí y no lo sería si CartView tuviera estado y lógica de negocio adentro?
Ver solución
const items = [
{ id: 'p1', name: 'Wireless Mouse', priceCents: 2599, qty: 2 },
{ id: 'p3', name: 'USB-C Hub', priceCents: 3499, qty: 1 },
];
const out = CartView({ items, onRemove: () => {} });
// el total esperado: 2599*2 + 3499 = 8697 centavos = $86.97
console.log(out.includes('TOTAL: $86.97') ? 'PASA' : 'FALLA');
Imprime PASA, porque el total de la vista es exactamente $86.97 (5198 + 3499), como viste en el ejemplo trabajado.
Esta prueba es posible porque CartView es puro: todo lo que muestra depende solo de sus props, así que le das props y compruebas la salida, sin montar React, sin DOM, sin simular clics. Si CartView tuviera estado interno o lógica de negocio (leyera un descuento de un servidor, guardara los items en su propio useState, dependiera de la hora), su salida ya no sería función solo de las props: la misma entrada podría dar salidas distintas, y no podrías predecir ni verificar la vista sin reproducir todo ese contexto. La pureza es lo que hace la prueba trivial —el mismo motivo por el que el reducer de la lección 4 se probaba tan fácil—.
Resumen y siguiente paso
En esta lección recogiste todo el módulo en un patrón de organización: container vs presentational. Los container tienen el estado y la lógica (el App con su useReducer, que decide); los presentational solo muestran sus props —el CartView que recibe items y onRemove, sin estado propio ni lógica de negocio—. Lo anclaste con el titiritero y el títere: uno tiene los hilos y la voluntad; el otro se mueve según le tiran, sin improvisar. Y lo mediste: el CartView es una función pura de props (out1 === out2 es true), y produjo la misma vista con datos fijos o con un reducer detrás —porque no le importa de dónde vienen los items—. De ahí sus tres virtudes: testeable, reutilizable, legible. Es el superpoder de la pureza (el mismo del reducer de la lección 4) aplicado a los componentes de vista.
Antes de avanzar deberías poder: clasificar un componente como container o presentational con la prueba "¿sabe algo sin sus props?"; extraer un presentational puro de un componente que mezcla estado y vista; y probar ese presentational sin React, dándole props y comprobando la salida.
La lección 8 es el mini-proyecto que junta el módulo entero en el storefront: el carrito levantado en el App, manejado por el cartReducer vía useReducer, compartido por el ProductList (que despacha add) y el Cart hermano (que muestra y despacha remove/clear), armado con composición (un Layout con children) para no encadenar props, y con la vista separada en presentational. Verás el árbol de componentes, el código React real, y una sesión de usuario ejecutada en Node —agregar, subir cantidad, quitar, vaciar— con el estado y el total del carrito.
Recursos
- React, "Keeping Components Pure" — react.dev/learn/keeping-components-pure. Por qué un componente debe ser una función pura de sus props (mismas props → misma salida); la propiedad que hace testeable al presentational. En inglés.
- React, "Passing Props to a Component" — react.dev/learn/passing-props-to-a-component. Cómo un presentational recibe todo por props (datos y callbacks/hilos), sin estado propio. En inglés.
- React, "Reacting to Input with State: Presentational vs container thinking" — react.dev/learn/reacting-to-input-with-state. Separar el estado (container) de cómo se ve la UI para ese estado (presentational); el marco mental de esta lección. En inglés.
- React, "Managing State" — react.dev/learn/managing-state. El índice que cierra con Context y estado compartido a escala —dónde el "container" pasa a ser un store, tema de
frontend-state-and-data. En inglés.