Módulo 7: Lifting State And Composition
Presentación del módulo: el carrito que dos hermanos comparten
Por qué este módulo existe aquí
En los módulos anteriores, cada componente resolvía lo suyo y guardaba su propio estado. En el módulo 3 le diste estado a un componente —su memoria propia, que persiste entre renders—; en el 4 conectaste eventos para que el usuario lo cambiara; en el 5 aprendiste a derivar valores en vez de guardarlos; en el 6 usaste efectos para sincronizar con el mundo externo. Cada pieza sabía lo que necesitaba y no molestaba a las demás. Pero apenas la app crece, aparece una pregunta que ninguno de esos módulos resuelve, y que este módulo enfrenta de lleno: ¿qué pasa cuando dos componentes distintos necesitan el mismo dato?
Míralo en Mercado, que ya conoces. El ProductList tiene los botones "Add to cart": ahí es donde se agrega al carrito. El Cart es el que muestra el carrito —los productos, las cantidades, el total—. Son dos componentes distintos, y en el árbol son hermanos: los dos cuelgan del App, ninguno está dentro del otro. Y aquí está el problema, que es una regla dura de React: un componente no puede leer el estado de su hermano. El estado de un componente es privado; solo él lo ve. Entonces, si el ProductList guarda el carrito en su propio estado, el Cart no tiene forma de verlo; y si cada uno guarda su propia copia, apenas agregues un producto los dos empiezan a contar cosas distintas. El ProductList cree que el carrito tiene un item; el Cart sigue mostrando cero. La app se contradice consigo misma.
Ese es el problema que abre el módulo, y tiene una solución canónica en React que da nombre a todo lo que sigue: levantar el estado (lifting state up). La idea es simple y poderosa: cuando dos componentes necesitan el mismo dato, ese dato no vive en ninguno de los dos. Sube al ancestro común más cercano —el componente de arriba del que ambos cuelgan—, y desde ahí baja por props a los dos. En Mercado, el ancestro común de ProductList y Cart es el App. Así que el carrito vive en el App, y baja: al ProductList le baja la forma de agregar (el callback onAddToCart), y al Cart le bajan los items para mostrar y la forma de quitar (onRemoveFromCart). Hay un solo carrito, en un solo lugar, y los dos hermanos lo leen de la misma fuente. Se acabó la contradicción.
Conexión con el módulo
Este módulo instala levantar el estado y las dos herramientas que lo acompañan cuando la cosa crece. La primera pieza es la técnica misma —subir el estado al ancestro común y bajarlo por props— y su criterio: no todo se levanta. El estado que solo un componente usa se queda local; subirlo "por si acaso" es estado global prematuro (lecciones 2 y 3). La segunda pieza aparece cuando la lógica del carrito crece —no solo agregar, también subir cantidades, quitar, vaciar—: meter todo eso en handlers sueltos se enreda, así que consolidamos la lógica en una función pura, el cartReducer de la forma (state, action) => newState, y la conectamos a React con useReducer (lecciones 4 y 5). La tercera pieza es la composición: cuando bajar el dato obliga a cruzar tres o cuatro componentes que no lo usan (prop drilling), pasamos children y componentes como props para que el dato salte esos niveles, y separamos container (tiene el estado) de presentational (solo muestra) (lecciones 6 y 7). El mini-proyecto (lección 8) junta todo en el storefront.
Y la promesa de siempre, que se cumple lección por lección: nada se cita de memoria, todo se ejecuta. React del navegador no se puede "correr y ver el DOM" aquí, así que hacemos lo honesto: el JSX real —useReducer, dispatch, children, el estado levantado— se muestra en los bloques de código (es la API exacta que escribirás), y la lógica pura se ejecuta en Node con solo JavaScript. Y este módulo tiene una ventaja especial para eso: el cartReducer es una función pura, y una función pura es el mejor amigo de quien quiere verificar —se prueba con una entrada y se compara la salida, sin navegador, sin trucos—. Cada salida en los bloques "Qué esperar" es la salida literal de correr el código con Node 18.
Una analogía: el termostato de la casa
Imagina una casa con varios cuartos: la sala, la recámara, la cocina. Todos comparten una misma cosa: la temperatura del aire. Ahora, ¿cómo controlas esa temperatura? Hay dos diseños posibles, y solo uno funciona.
El diseño malo: le pones un termostato a cada cuarto, y cada uno guarda su propia idea de "la temperatura de la casa". El de la sala dice 22°, el de la recámara dice 20°, el de la cocina dice 24°. Pero el aire es uno solo: no puede estar a tres temperaturas a la vez. Los termostatos se contradicen, y peor: si subes el de la sala, los otros dos ni se enteran, siguen marcando lo suyo. Tienes tres "verdades" para un solo hecho, y ninguna es confiable. Eso es exactamente lo que pasa cuando dos componentes hermanos guardan cada uno su propia copia del carrito.
El diseño bueno: un solo termostato, en un lugar central —el pasillo, digamos—, que es la única fuente de verdad de la temperatura. Todos los cuartos leen de él. Si quieres cambiar la temperatura, vas a ese termostato (o le mandas la orden), y como es uno solo, el cambio lo ven todos los cuartos al mismo tiempo. No hay contradicción posible: hay un número, en un lugar, y toda la casa lo comparte. Ese termostato central es el estado levantado: el carrito que vive en el App en vez de en cada hermano.
Fíjate en el reparto, porque es la lección entera. El termostato central es el App: tiene el dato (el carrito) y es el único que lo cambia. Los cuartos son los hijos —ProductList y Cart—: leen del termostato, y cuando quieren cambiar la temperatura no guardan su propia versión, sino que le avisan al termostato central. La temperatura baja a los cuartos (todos la ven); las órdenes de cambio suben al termostato (que decide). Una sola fuente de verdad, muchos lectores. Guarda esta imagen: cuando dos partes de la app comparten un dato, el dato va en el termostato central, no en cada cuarto.
El caso que nos acompaña: el storefront de Mercado, ahora compartido
Seguimos con el storefront de Mercado —el frontend de la tienda: la barra de búsqueda, la lista de productos, el carrito—. Hasta el módulo 4 le conectamos eventos, y el App recibía los datos que subían. En este módulo diseñamos dónde vive el estado que esos eventos cambian, y el caso protagonista es el carrito, porque lo tocan dos hermanos:
- El
ProductList(con susProductCard) tiene los botones "Add to cart": es donde el carrito crece. - El
Cart(con susCartItem) muestra el carrito y tiene los botones para quitar o vaciar: es donde el carrito se lee y se reduce.
Los dos hablan del mismo carrito, pero son piezas separadas del árbol. El carrito, entonces, sube al ancestro común —el App— y baja a cada hermano lo que necesita. Y como la lógica del carrito no es trivial (agregar sube cantidad si ya está, quitar la baja, vaciar borra todo), la consolidamos en el cartReducer. Recuerda los datos: un producto (Product) tiene id, name, priceCents (el precio en centavos, entero: 2599), category e inStock; al mostrarlo lo formateamos con formatPrice(2599) → "$25.99". Este es el árbol, con el carrito levantado en el App:
flowchart TD
App["App (estado: cart · dispatch)"]
SB[SearchBar]
PL[ProductList]
Cart[Cart]
PC1[ProductCard]
PC2[ProductCard]
CI[CartItem]
App -- "onAddToCart" --> PL
App -- "items, onRemoveFromCart" --> Cart
PL --> PC1
PL --> PC2
Cart --> CI
PC1 -. "onAddToCart(product)" .-> App
CI -. "onRemoveFromCart(id)" .-> App
El carrito vive arriba, en el App (la caja del termostato). Baja a ProductList la forma de agregar (onAddToCart) y baja a Cart los items para mostrar y la forma de quitar (onRemoveFromCart). Las flechas continuas son props que bajan; las punteadas son datos que suben por callbacks (lo del módulo 4). La novedad de este módulo es dónde está el estado: no en un hermano, sino en el ancestro que ambos comparten.
Ejemplo trabajado: el carrito levantado en App
Nada convence como verlo. Vamos a montar el carrito levantado en el App, manejado por un reducer, y a comprobar que los dos hermanos —el que agrega y el que muestra— siempre concuerdan, porque leen la misma fuente. Primero, cómo se ve en React de verdad. Esto es JSX real; obsérvalo:
function App() {
// El carrito vive AQUI, en el ancestro comun de ProductList y Cart.
const [cart, dispatch] = useReducer(cartReducer, []);
return (
<div className="app">
{/* al ProductList le baja la forma de AGREGAR */}
<ProductList
products={PRODUCTS}
onAddToCart={(product) => dispatch({ type: 'add', product })}
/>
{/* al Cart hermano le bajan los ITEMS y la forma de QUITAR */}
<Cart
items={cart}
onRemoveFromCart={(id) => dispatch({ type: 'remove', id })}
/>
</div>
);
}
Léelo con la analogía del termostato. El App es el termostato central: tiene el cart (el useReducer lo veremos a fondo en la lección 5; por ahora, cart es el estado y dispatch la forma de cambiarlo). El ProductList y el Cart son los cuartos: ninguno guarda su propio carrito. Al ProductList le baja onAddToCart (su orden de "sube la temperatura"); al Cart le bajan los items (la temperatura, para mostrarla) y onRemoveFromCart (su orden de "bájala"). Un solo carrito, dos hermanos leyéndolo.
Ahora ejecutémoslo. Modelamos el App con su carrito y su dispatch, y hacemos que el ProductList "haga clic en Add" y el Cart "haga clic en Remove", imprimiendo en cada cambio lo que ven los dos hermanos —para comprobar que nunca se contradicen—:
// El cartReducer: una funcion PURA (state, action) => newState. (A fondo, leccion 4.)
function cartReducer(state, action) {
switch (action.type) {
case 'add': {
const line = state.find((item) => item.id === action.product.id);
if (line) { // ya esta: sube la cantidad SIN mutar
return state.map((item) =>
item.id === action.product.id ? { ...item, qty: item.qty + 1 } : item);
}
return [...state, { ...action.product, qty: 1 }]; // nuevo: linea con qty 1
}
case 'remove': {
const line = state.find((item) => item.id === action.id);
if (line && line.qty > 1) { // baja la cantidad en 1
return state.map((item) =>
item.id === action.id ? { ...item, qty: item.qty - 1 } : item);
}
return state.filter((item) => item.id !== action.id); // qty 0: quita la linea
}
case 'clear': return [];
default: return state;
}
}
function cartTotal(state) { return state.reduce((s, i) => s + i.priceCents * i.qty, 0); }
function formatPrice(cents) { return '$' + (cents / 100).toFixed(2); }
// --- App: dueño del carrito (una sola fuente de verdad) ---
let cart = [];
function dispatch(action) { cart = cartReducer(cart, action); render(); }
function render() {
const items = cart.map((i) => `${i.name} x${i.qty}`).join(', ');
// Imprime lo que ven los DOS hermanos: leen el MISMO cart del App.
console.log(` [ProductList] botones "Add" listos | [Cart] muestra: [${items}] total ${formatPrice(cartTotal(cart))}`);
}
const MOUSE = { id: 'p1', name: 'Wireless Mouse', priceCents: 2599 };
const KEYBOARD = { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 };
console.log('=== El carrito levantado en App: dos hermanos, una sola verdad ===\n');
render();
console.log('\nProductList: click "Add" en el Mouse ->');
dispatch({ type: 'add', product: MOUSE });
console.log('ProductList: click "Add" en el Keyboard ->');
dispatch({ type: 'add', product: KEYBOARD });
console.log('ProductList: click "Add" en el Mouse otra vez ->');
dispatch({ type: 'add', product: MOUSE });
console.log('Cart: click "Remove" en el Keyboard ->');
dispatch({ type: 'remove', id: KEYBOARD.id });
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== El carrito levantado en App: dos hermanos, una sola verdad ===
[ProductList] botones "Add" listos | [Cart] muestra: [] total $0.00
ProductList: click "Add" en el Mouse ->
[ProductList] botones "Add" listos | [Cart] muestra: [Wireless Mouse x1] total $25.99
ProductList: click "Add" en el Keyboard ->
[ProductList] botones "Add" listos | [Cart] muestra: [Wireless Mouse x1, Mechanical Keyboard x1] total $114.99
ProductList: click "Add" en el Mouse otra vez ->
[ProductList] botones "Add" listos | [Cart] muestra: [Wireless Mouse x2, Mechanical Keyboard x1] total $140.98
Cart: click "Remove" en el Keyboard ->
[ProductList] botones "Add" listos | [Cart] muestra: [Wireless Mouse x2] total $51.98
Lee la salida siguiendo el dato. El estado inicial muestra el carrito vacío (total $0.00): los dos hermanos parten de la misma verdad. Luego el ProductList "hace clic en Add" del mouse: despacha { type: 'add', product: MOUSE }, el App corre el reducer, el carrito pasa a [Wireless Mouse x1], y —esto es lo importante— el Cart lo muestra al instante, con su total $25.99. El clic ocurrió en un hermano (ProductList), pero el otro (Cart) lo ve, porque ambos leen el carrito del App, no una copia propia.
El segundo clic agrega el keyboard: el carrito ahora tiene dos líneas, x1 cada una, total $114.99 (2599 + 8900). El tercer clic es otra vez el mouse: en lugar de una línea nueva, el reducer sube la cantidad —Wireless Mouse x2—, y el total sube a $140.98. Por último, el Cart "hace clic en Remove" del keyboard: el otro hermano dispara el cambio, el reducer quita esa línea, y el carrito queda en [Wireless Mouse x2], total $51.98.
Detente en lo esencial: en ningún momento hubo dos versiones del carrito. Agregara quien agregara —el ProductList o el Cart—, había un solo cart en el App, y los dos hermanos siempre mostraron lo mismo. Compáralo con el termostato: no hay un termostato por cuarto contradiciéndose; hay uno central, y toda la casa lee de él. Eso es levantar el estado, y es lo que hace que la app sea consistente.
El mapa del módulo
Guarda esta ruta; es cómo cada lección construye una parte de "levantar el estado y componer":
Tema Leccion Idea clave
──────────────────────────────── ──────── ──────────────────────────────────────────
Levantar el estado L2 dos hermanos que comparten un dato: el estado
sube al ancestro comun y baja por props
Estado local vs levantado L3 local por defecto; sube SOLO cuando se
comparte; levantar de mas = estado global caro
El cartReducer (funcion pura) L4 (state, action) => newState; add/remove/clear;
no muta; se prueba sin navegador
useReducer en React L5 [cart, dispatch] = useReducer(reducer, init);
despachas acciones; reducer vs useState
Composicion sobre prop drilling L6 pasar children y componentes como props para
que el dato salte los niveles intermedios
Container y presentational L7 container tiene estado/logica; presentational
es una funcion pura de props (solo muestra)
──────────────────────────────── ──────── ──────────────────────────────────────────
El carrito compartido L8 el mini-proyecto, ejecutado
La frontera: qué NO entra en este módulo
Saber la frontera te evita esperar cosas que llegan en otra guía del ecosistema Fullstack.
- Context (evitar el prop drilling a escala) es de la guía
frontend-state-and-data. En este módulo, cuando el prop drilling se pone incómodo, la respuesta es componer (pasarchildren). Pero cuando muchísimos componentes en muchos niveles necesitan el mismo dato (el usuario logueado, el tema claro/oscuro, el idioma), ni componer alcanza, y React ofrece Context para "teletransportar" un valor a cualquier descendiente sin pasarlo por props. Lo mencionamos como el siguiente paso (lecciones 6 y 7), pero se enseña allá. - El estado global (Zustand, Redux) y los datos del servidor (React Query, SWR, caché, mutaciones) también son de
frontend-state-and-data. Aquí el estado vive en un ancestro común dentro de tu árbol de React (useState/useReducer+ lifting). Cuando el estado tiene que vivir fuera del árbol, o sincronizarse con un servidor, esa es la otra guía. useReducersí es de este módulo, pero como herramienta de estado local complejo (el carrito, en elApp). No lo usamos para estado global; para eso está lo de arriba. La línea es clara: reducer para consolidar lógica; Context/Zustand para dónde vive el estado a gran escala.- Y todo lo que los módulos anteriores ya delimitaron sigue igual: HTML/CSS a fondo →
web-fundamentals-html-css; Next.js/SSR/routing →nextjs-app-router; estilos/design systems →ui-systems-and-design-implementation; performance/deploy →fullstack-performance-and-deployment.
Errores comunes
Guardar el carrito en un hermano en vez de en el ancestro común. Qué pasa: pones el useState del carrito dentro del ProductList (donde están los botones "Add"), y entonces el Cart —que es su hermano— no tiene forma de verlo. Por qué pasa: parece natural que el estado viva "donde se cambia". Cómo detectarlo: agregas productos y el ProductList reacciona, pero el Cart sigue vacío; los dos hermanos muestran cosas distintas. Cómo corregirlo: levanta el carrito al ancestro común de los dos —el App— y bájalo por props. El estado vive donde varios lo comparten, no donde uno lo cambia. Es la lección 2 entera.
Levantar TODO el estado al App "por si acaso". Qué pasa: subes al tope hasta el estado que solo un componente usa —el "ver más detalles" de una tarjeta, el foco de un input—, y el App se llena de estado que no le incumbe, mientras cada cambio menor re-renderiza medio árbol. Por qué pasa: después de aprender a levantar, se siente "más seguro" tener todo arriba. Cómo detectarlo: el App tiene diez useState de los cuales la mitad los usa un solo hijo lejano. Cómo corregirlo: el estado que solo un componente usa se queda local; solo sube lo que se comparte. Levantar de más es estado global prematuro. Es la lección 3.
Creer que este módulo necesita Context o Redux para funcionar. Qué pasa: al ver "estado compartido", alguien piensa que ya hace falta una librería de estado global o Context, y complica el diseño antes de tiempo. Por qué pasa: se confunde "compartido entre dos hermanos" con "compartido por toda la app". Cómo detectarlo: instalas Redux para un carrito que dos componentes comparten. Cómo corregirlo: para dos hermanos (o unos pocos niveles), levantar el estado al ancestro común basta y sobra. Context y el estado global son para cuando el dato lo necesita medio árbol; eso es la guía frontend-state-and-data. Empieza simple; sube la herramienta solo cuando el problema lo pida.
Ejercicios
Ejercicio 1 — Encuentra el termostato. En una app tienes este árbol: App → (Header con un CartBadge que muestra cuántos items hay) y (Main → ProductList → ProductCard con el botón "Add"). El CartBadge (arriba a la izquierda) y los botones "Add" (abajo a la derecha) hablan del mismo carrito. ¿En qué componente debe vivir el estado del carrito, y por qué ahí y no en otro?
Ver solución
El carrito debe vivir en el App, porque es el ancestro común más cercano de los dos componentes que lo necesitan: el CartBadge (que lo muestra) cuelga de Header, que cuelga de App; y los botones "Add" cuelgan de ProductCard → ProductList → Main → App. El único componente que está arriba de ambos es el App.
No puede vivir en el CartBadge ni en el ProductCard, porque son ramas distintas del árbol y no pueden ver el estado del otro (un componente no lee el estado de otro que no sea su ancestro). Tampoco tiene sentido ponerlo más arriba de lo necesario (no hay nada arriba del App aquí, pero la regla general es: el ancestro común más cercano, no "el de más arriba"). Desde el App, el conteo baja al CartBadge para mostrarlo, y el callback para agregar baja hasta el ProductCard. Un solo carrito, en el termostato central.
Ejercicio 2 — ¿Por qué se contradicen? Un compañero puso un useState([]) del carrito dentro del ProductList y otro useState([]) dentro del Cart. Prueba la app: agrega dos productos desde el ProductList y mira el Cart. Describe qué verá el usuario y por qué, usando la analogía del termostato.
Ver solución
El usuario verá una contradicción: el ProductList "cree" que el carrito tiene dos productos (porque agregó a su estado), pero el Cart seguirá mostrando cero, porque su propio useState nunca se tocó. Cada hermano tiene su propia copia del carrito, y agregar a una no cambia la otra.
Con la analogía: es como ponerle un termostato a cada cuarto. Subiste el de la sala (ProductList) a "2 items", pero el de la recámara (Cart) sigue en "0", porque son aparatos distintos que no se hablan. Hay dos "verdades" para un solo hecho, y no coinciden. La corrección es tener un solo termostato central: levantar el carrito al App (el ancestro común), y que los dos hermanos lean de ahí. Entonces agregar desde el ProductList se refleja de inmediato en el Cart, porque miran el mismo dato.
Ejercicio 3 — ¿De qué guía es cada cosa? Para cada situación, di si se resuelve en este módulo (levantar el estado / reducer / composición) o en la guía frontend-state-and-data (Context / estado global): (a) el ProductList y el Cart, hermanos, comparten el carrito; (b) el tema claro/oscuro lo necesitan 40 componentes en 6 niveles distintos; (c) la lógica del carrito (agregar, quitar, vaciar) se volvió un enredo de handlers; (d) el carrito debe sincronizarse con el servidor y recargarse al volver a la página.
Ver solución
- (a) Este módulo. Dos hermanos que comparten un dato → levantar el estado al ancestro común (
App). No hace falta nada más. - (b)
frontend-state-and-data. Un dato que necesitan muchísimos componentes en muchos niveles → Context (para no encadenar la prop por todos lados). Aquí lo mencionamos; se enseña allá. - (c) Este módulo. Lógica de estado compleja → consolidarla en un
cartReducerconuseReducer. Es la lección 4-5. - (d)
frontend-state-and-data. Sincronizar con el servidor, recargar, cachear → datos del servidor (React Query/SWR). Fuera del alcance de esta guía.
La regla mental: compartir entre pocos, dentro de tu árbol es de aquí (lifting + composición); compartir a escala o con el servidor es de frontend-state-and-data. Empieza con lo simple y sube la herramienta solo cuando el problema lo pida.
Resumen y siguiente paso
En esta lección viste el problema que abre el módulo —dos componentes hermanos (ProductList que agrega, Cart que muestra) necesitan el mismo carrito, pero un componente no puede leer el estado de su hermano— y la solución canónica de React: levantar el estado al ancestro común más cercano (el App), desde donde baja por props a los dos hijos. Lo anclaste con el termostato de la casa: una sola fuente de verdad de la temperatura que todos los cuartos leen, en vez de un termostato por cuarto que se contradicen. Y lo mediste ejecutando el carrito levantado en el App: agregara quien agregara, los dos hermanos siempre mostraron lo mismo, porque leían el mismo cart.
Antes de avanzar deberías poder: reconocer cuándo dos componentes comparten un dato y por qué eso obliga a levantar el estado; ubicar el ancestro común más cercano de dos componentes en un árbol; explicar con el termostato por qué el estado local duplicado se desincroniza; y ubicar la frontera —qué es de este módulo (levantar, reducer, componer) y qué es de frontend-state-and-data (Context, estado global, datos del servidor)—.
La lección 2 toma la técnica y la clava paso a paso: levantar el estado al ancestro común. Vas a ver, ejecutado y medido, el contraste completo —dos hermanos con estado local separado que se desincronizan (contradicción false) frente al estado levantado que los mantiene consistentes (true)— y los tres pasos concretos de levantar: encontrar el ancestro, mover el estado, y bajar el dato a quien lo muestra y el callback a quien lo cambia.
Recursos
- React, "Sharing State Between Components" — react.dev/learn/sharing-state-between-components. La página oficial que introduce lifting state up: por qué el estado compartido sube al ancestro común y baja por props. El corazón de este módulo. En inglés.
- React, "Managing State" — react.dev/learn/managing-state. El índice de la sección completa que cubre levantar el estado, reducers, Context y composición; todo este módulo vive aquí. En inglés.
- React, "Choosing the State Structure" — react.dev/learn/choosing-the-state-structure. Cómo decidir dónde y cómo vive el estado —evitar duplicarlo entre componentes es justo el problema del termostato—. En inglés.
- React, "Passing Data Deeply with Context" — react.dev/learn/passing-data-deeply-with-context. El siguiente paso cuando el prop drilling es a gran escala; lo mencionamos y se enseña en
frontend-state-and-data. En inglés.