Módulo 7: Lifting State And Composition
Estado local vs levantado: cuándo sube y cuándo se queda
Descripción
La lección 2 te dio una técnica poderosa —levantar el estado— y te dejó con una tentación peligrosa: si levantar arregla la desincronización, ¿por qué no levantar todo al App y olvidarse del problema? Esta lección responde esa pregunta con un criterio que usarás en cada componente que escribas: local por defecto, sube solo cuando se comparte. No todo el estado quiere subir. El que solo un componente usa —el "ver más detalles" de una tarjeta, si un input tiene foco, si un menú está abierto— se queda local, cerca de donde se usa. El que dos o más componentes necesitan —el carrito— se levanta. Y hay un error opuesto al de la lección 1: mientras allá el problema era no levantar lo compartido, aquí el problema es levantar de más —estado global prematuro que acopla piezas que no tenían por qué conocerse—. Lo vas a ver ejecutado: un estado local que se mantiene sano e independiente por componente, frente al carrito que sí debe compartirse.
Conexión con el módulo. Es el criterio que le faltaba a la lección 2. La 2 te enseñó cómo levantar; esta te enseña cuándo —y cuándo no—. Juntas forman la decisión completa de dónde vive cada estado, que es la base de todo lo que sigue: el reducer (lecciones 4-5) maneja el estado del carrito una vez decidido que va levantado en el App; la composición (lecciones 6-7) organiza cómo baja. Sin este criterio, levantarías de más y llenarías el App de estado que no le incumbe.
Una analogía: el termostato compartido y la lámpara de tu buró
Vuelve a la casa de la lección 1, pero mira ahora dos cosas distintas que se controlan en ella, porque cada una pide un diseño diferente.
La temperatura es compartida: el aire es uno solo para toda la casa, así que va en el termostato central —arriba, común, una sola fuente de verdad—. Eso ya lo sabes: es el estado levantado. Pero piensa en la lámpara del buró de tu recámara. Esa lámpara es tuya y de nadie más: cuando la enciendes, no tiene por qué cambiar la de la recámara de tu hermano, ni la de la sala. Sería absurdo poner todas las lámparas de la casa en un interruptor central: encenderías la tuya y se prenderían las cinco, o para prender la tuya tendrías que ir al pasillo. La lámpara de cada buró quiere su propio interruptor, local, en su propio cuarto. Ese es el estado local: cada componente con el suyo, independiente.
La regla que sale de aquí es la lección entera: lo que se comparte, va arriba (levantado); lo que es de uno, se queda cerca (local). Y cuidado con los dos errores simétricos. Poner la temperatura en un termostato por cuarto (no levantar lo compartido) desincroniza la casa —el problema de la lección 1—. Poner todas las lámparas en un interruptor central (levantar lo que no se comparte) acopla cuartos que no tenían nada que ver —el problema de esta lección—. El buen diseño da a cada dato el lugar que le corresponde: ni todo arriba, ni todo abajo.
En React, el termostato es el carrito levantado en el App (lo comparten ProductList y Cart). La lámpara del buró es, por ejemplo, el expanded de un ProductCard —si muestra o no sus detalles—: es asunto solo de esa tarjeta, así que vive en su propio useState, local. Que una tarjeta se expanda no debe afectar a las demás; cada una con su interruptor.
Ejemplo trabajado: el estado local que se queda independiente, y el que sí se comparte
Vamos a ver las dos naturalezas lado a lado. Primero, un estado que debe quedarse local: el expanded de cada ProductCard (si muestra sus detalles). Montamos dos tarjetas, cada una con su propio expanded, y comprobamos que abrir una no toca a la otra —justo lo que queremos, y justo lo que se rompería si lo levantáramos de más—. Luego, un estado que sí se comparte: el carrito, que dos tarjetas alimentan y que debe verlo todo junto.
Primero, cómo se ve el estado local en React de verdad. El useState del expanded vive dentro del ProductCard, uno por tarjeta:
function ProductCard({ product, onAddToCart }) {
// estado LOCAL: solo esta tarjeta usa su "expanded". No sube a ningun lado.
const [expanded, setExpanded] = useState(false);
return (
<article className="product-card">
<h3>{product.name}</h3>
<button onClick={() => setExpanded((e) => !e)}>
{expanded ? 'Hide details' : 'Show details'}
</button>
{expanded && <p>{product.description}</p>}
{/* "Add to cart" SI sube (callback), porque el carrito se comparte */}
<button onClick={() => onAddToCart(product)}>Add to cart</button>
</article>
);
}
Fíjate en el contraste dentro de una misma tarjeta: expanded es local (un useState propio, porque solo esta tarjeta lo usa), pero "Add to cart" sube por un callback (porque el carrito lo comparte con el Cart). Dos datos, dos destinos, según quién los use. Ahora ejecutemos, modelando cada tarjeta con su celda local independiente:
// Estado LOCAL: cada ProductCard tiene su propio "expanded" (ver detalles).
// Que una tarjeta se expanda NO afecta a las otras -> se queda LOCAL.
function makeCard(name) {
let expanded = false; // estado LOCAL de esta tarjeta
return {
toggle() { expanded = !expanded; },
view() { return `${name}: ${expanded ? 'detalles ABIERTOS' : 'detalles cerrados'}`; },
};
}
console.log('=== Estado LOCAL: independiente por tarjeta ===\n');
const cardA = makeCard('Wireless Mouse');
const cardB = makeCard('Mechanical Keyboard');
console.log(cardA.view());
console.log(cardB.view());
console.log('\nEl usuario abre los detalles SOLO de la tarjeta A:');
cardA.toggle();
console.log(cardA.view());
console.log(cardB.view()); // sigue cerrada: el estado local de A no toca a B
console.log('\n=== Estado COMPARTIDO: el carrito, uno solo levantado ===\n');
let appCart = [];
function addToCart(name) { appCart = [...appCart, name]; }
addToCart('Wireless Mouse'); // desde la tarjeta A
addToCart('Mechanical Keyboard'); // desde la tarjeta B
console.log(`Cart (en App) ve TODO lo agregado: [${appCart.join(', ')}] (${appCart.length} items)`);
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== Estado LOCAL: independiente por tarjeta ===
Wireless Mouse: detalles cerrados
Mechanical Keyboard: detalles cerrados
El usuario abre los detalles SOLO de la tarjeta A:
Wireless Mouse: detalles ABIERTOS
Mechanical Keyboard: detalles cerrados
=== Estado COMPARTIDO: el carrito, uno solo levantado ===
Cart (en App) ve TODO lo agregado: [Wireless Mouse, Mechanical Keyboard] (2 items)
Lee las dos partes por separado, porque muestran las dos naturalezas.
En el estado local, cada tarjeta arranca con sus detalles cerrados. El usuario abre los detalles solo de la tarjeta A, y la salida lo confirma: A queda detalles ABIERTOS, y B sigue detalles cerrados. Eso es exactamente lo que queremos: el expanded de A es suyo, independiente del de B. Cada tarjeta tiene su propia celda (su propia lámpara de buró), y tocar una no toca la otra. Si hubiéramos "levantado" el expanded a un solo estado en el App, abrir una tarjeta habría abierto todas —o habríamos tenido que inventar un mapa de "qué tarjeta está abierta" en el App, complicando algo que estaba resuelto localmente—. Local es lo correcto aquí.
En el estado compartido, el carrito es otra historia: la tarjeta A agrega el mouse y la tarjeta B agrega el keyboard, y el Cart (en el App) ve las dos cosas juntas: [Wireless Mouse, Mechanical Keyboard], 2 items. Aquí sí queremos una sola fuente que reúna lo de todos —el termostato central—, porque el carrito es de la app entera, no de una tarjeta.
La moraleja en una línea: el mismo componente (ProductCard) tiene estado local (expanded, que se queda) y participa en estado levantado (el carrito, vía callback). No es que un componente sea "de estado local" o "de estado levantado"; es que cada dato decide su lugar según quién lo use.
Profundización: cómo decidir dónde vive un estado
La decisión es una sola pregunta: ¿quién necesita este dato?
- Lo necesita un solo componente → estado local, con
useState(ouseReducer) dentro de ese componente. Ejemplos: si un input tiene foco, si un acordeón está abierto, el texto a medio escribir de un formulario que nadie más lee, elexpandedde una tarjeta. - Lo necesitan dos o más componentes (para mostrarlo o cambiarlo) → estado levantado al ancestro común más cercano, bajando por props (lección 2). Ejemplos: el carrito (lo alimenta
ProductList, lo muestraCart), elqueryde búsqueda (lo tecleaSearchBar, filtraProductList).
Empieza local; sube solo cuando duela. El mejor orden es siempre el conservador: pon el estado lo más cerca posible de donde se usa —local—, y solo levántalo cuando aparezca un segundo componente que lo necesite. No adivines el futuro levantando "por si algún día se comparte". Si ese día llega, levantarlo es un cambio mecánico (los tres pasos de la lección 2). Levantar de más hoy es pagar un costo real por un beneficio que quizás nunca llegue.
Qué cuesta levantar de más (el estado global prematuro). Subir al App un estado que solo un hijo usa tiene precios concretos:
- Acopla lo que no debería. El
Apptermina conociendo detalles internos de un hijo lejano (si su acordeón está abierto), y ese hijo deja de ser autónomo: para entenderlo, hay que subir alApp. - Ensucia el ancestro. El
Appse llena deuseStateque no le incumben, y cuesta más leer qué estado de verdad coordina la app. - Re-renderiza de más. Cuando el estado vive en el
App, cambiarlo re-renderiza elAppy su subárbol. Si ese estado era un detalle de una tarjeta, ahora un cambio trivial mueve mucho más de lo necesario. - Rompe el reuso. Un
ProductCardque guarda suexpandedlocalmente se puede pegar en cualquier lado y funciona solo. Uno que depende de que elApple maneje elexpandedya no es portátil: arrastra alAppa donde vaya.
El caso intermedio: cuando un padre coordina a sus hijos. A veces el ancestro común no es el App, sino un padre más cercano. Si un Accordion tiene varios AccordionItem y solo uno puede estar abierto a la vez, el "cuál está abierto" se levanta al Accordion (no al App) —es el ancestro común más cercano de los items que se coordinan—. Levantar es "tan arriba como haga falta", y muchas veces "lo que haga falta" es un padre intermedio, no el tope. Subir hasta el App cuando un padre bastaba también es levantar de más.
Errores comunes
Levantar el estado que solo un componente usa. Qué pasa: subes al App el expanded de las tarjetas, el foco de un input, o el "menú abierto" de un dropdown, y el App se vuelve un basurero de estado ajeno. Por qué pasa: después de la lección 2, "levantar" se siente como la respuesta a todo. Cómo detectarlo: tienes estado en el App que un solo hijo lejano usa, y ningún otro componente lo lee. Cómo corregirlo: bájalo de vuelta a ese hijo como useState local. Pregúntate siempre "¿alguien más necesita este dato?"; si la respuesta es no, es local.
No levantar el estado que sí se comparte (esperar a que "se arregle solo"). Qué pasa: dejas el carrito local en el ProductList porque "por ahora funciona", y cuando agregas el Cart te encuentras la desincronización de la lección 1. Por qué pasa: se pospone la decisión de dónde vive el estado. Cómo detectarlo: dos componentes muestran o cambian el mismo dato y no coinciden. Cómo corregirlo: en cuanto un segundo componente necesite el dato, levántalo (lección 2). El criterio no es "empieza local y quédate local pase lo que pase"; es "empieza local y sube en cuanto se comparta".
Duplicar el estado: levantado y una copia local. Qué pasa: levantas el carrito al App pero dejas al Cart copiándolo a un useState propio "para trabajar más cómodo". Ahora hay dos fuentes y se desincronizan otra vez. Por qué pasa: la costumbre de guardar en estado lo que llega por props. Cómo detectarlo: el Cart no refleja cambios del carrito del App. Cómo corregirlo: un dato tiene una sola fuente de verdad. Si llega por props, se usa directo, no se copia a estado local. (Es "derivar, no guardar", del módulo 5, aplicado aquí.)
Ejercicios
Ejercicio 1 — Local o levantado. Para cada estado, di si debe ser local (y en qué componente) o levantado (y al ancestro común de quiénes), y por qué: (a) si el menú desplegable del Header está abierto; (b) el query de la búsqueda, que teclea SearchBar y usa ProductList para filtrarse; (c) el texto que el usuario escribe en el campo "código de descuento" del Cart, que nadie más lee hasta que da "Aplicar"; (d) el carrito.
Ver solución
- (a) Local, en el
Header(o en el propio componente del menú). Si el dropdown está abierto es asunto solo de ese menú; nadie más lo necesita. Es la lámpara de buró. - (b) Levantado, al
App(ancestro común deSearchBaryProductList). Dos componentes lo comparten: uno lo cambia, otro lo usa para filtrar. Es el termostato. - (c) Local, en el
Cart(o en el propio campo de descuento). Mientras nadie más lo lea, el texto a medio escribir se queda cerca. Si al dar "Aplicar" ese código tuviera que afectar al total que otros componentes muestran, entonces el descuento aplicado (no el texto a medio escribir) subiría; pero el borrador del input es local. - (d) Levantado, al
App(ancestro común deProductListyCart). El caso protagonista del módulo: lo alimenta uno, lo muestra otro.
La pregunta que decide siempre: ¿alguien más necesita este dato? Sí → levantar. No → local.
Ejercicio 2 — El costo de levantar de más. Un compañero levantó el expanded de todas las tarjetas a un solo estado en el App: const [expandedId, setExpandedId] = useState(null). Explica dos problemas concretos que esto causa (usa la analogía y lo que ejecutaste), y di en qué caso sí tendría sentido coordinar el expanded desde arriba.
Ver solución
Dos problemas concretos:
- Acopla y ensucia. El
Appahora conoce un detalle interno de las tarjetas (cuál está expandida) que no le incumbe, y elProductCarddeja de ser autónomo: ya no maneja su propio "abrir/cerrar", depende delApp. Pegar eseProductCarden otra pantalla obliga a arrastrar ese estado al nuevoApp. Es como poner todas las lámparas de buró en un interruptor central. - Re-render de más. Cambiar
expandedIdre-renderiza elAppy su subárbol entero cada vez que alguien abre o cierra una tarjeta —un detalle visual de una sola tarjeta ahora mueve toda la app—.
En qué sí tendría sentido: si el requisito fuera "solo una tarjeta puede estar expandida a la vez" (abrir una cierra las demás), entonces las tarjetas se coordinan entre sí, y el "cuál está abierta" pasa a ser un dato compartido → se levanta al ancestro común (aquí, ProductList, no necesariamente el App). La diferencia es si las tarjetas son independientes (local) o se coordinan (levantado). Sin ese requisito, es local.
Ejercicio 3 — El mismo componente, dos naturalezas. Mira el ProductCard del ejemplo trabajado: tiene expanded (local) y "Add to cart" (que sube por callback). Explica por qué esos dos datos tienen destinos distintos, aunque vivan en el mismo componente. ¿Qué pregunta usarías para decidir el destino de un tercer dato hipotético —digamos, "cuántas unidades de este producto quiere el usuario antes de agregarlo"?
Ver solución
Tienen destinos distintos porque los usa gente distinta:
expandedlo usa solo la tarjeta (mostrar u ocultar sus detalles). Nadie más lo necesita → local.- El "Add to cart" afecta al carrito, que comparten
ProductListyCart→ el dato (el producto agregado) sube por callback alApp, donde vive el carrito levantado.
Un componente no es "de estado local" o "de estado levantado" en bloque: cada dato decide su lugar según quién lo use.
Para el tercer dato ("cuántas unidades quiere antes de agregar"): la pregunta es siempre ¿alguien más lo necesita?. Mientras sea solo un contador dentro de la tarjeta, antes de dar "Add", es local (const [qty, setQty] = useState(1)). Solo lo que termina en el carrito sube: al dar "Add", el callback sube product (y quizás la cantidad elegida). El borrador —el contador antes de confirmar— se queda local.
Resumen y siguiente paso
En esta lección aprendiste el criterio que gobierna dónde vive cada estado: local por defecto, sube solo cuando se comparte. El estado que un solo componente usa se queda local (la lámpara de tu buró); el que dos o más necesitan se levanta al ancestro común (el termostato central). Lo mediste: dos tarjetas con expanded local se mantuvieron independientes (abrir A no tocó a B), mientras el carrito —compartido— sí reunió lo de ambas en una sola fuente. Y viste el error opuesto al de la lección 1: levantar de más —estado global prematuro— que acopla, ensucia el ancestro, re-renderiza de más y rompe el reuso. La regla operativa: empieza local, y sube en cuanto aparezca un segundo componente que necesite el dato.
Antes de avanzar deberías poder: decidir para cualquier estado si es local o levantado con la pregunta "¿alguien más lo necesita?"; nombrar los costos concretos de levantar de más; y ver que un mismo componente puede tener estado local y participar en estado levantado, dato por dato.
Con dónde vive el estado ya decidido —el carrito, levantado en el App—, la lección 4 ataca el cómo: la lógica del carrito ya no es trivial (agregar sube cantidad si el producto ya está, quitar la baja, vaciar borra todo), y meterla en handlers sueltos se enreda. Vas a consolidarla en un cartReducer: una función pura de la forma (state, action) => newState. Y como es pura, la vas a probar sin navegador, corriéndola con una secuencia de acciones y viendo el estado y el total en centavos, literal. Es el corazón ejecutable del módulo.
Recursos
- React, "Sharing State Between Components: Controlled and uncontrolled components" — react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components. Cómo decidir si un estado lo maneja el propio componente (local) o su padre (levantado); el criterio de esta lección. En inglés.
- React, "Choosing the State Structure: Avoid duplication in state" — react.dev/learn/choosing-the-state-structure#avoid-duplication-in-state. Por qué duplicar un estado (levantado y copiado local) lleva a la desincronización; el tercer error común. En inglés.
- React, "Preserving and Resetting State" — react.dev/learn/preserving-and-resetting-state. Cómo React mantiene el estado local por posición en el árbol —por qué cada
ProductCardconserva su propioexpandedindependiente. En inglés. - React, "Thinking in React: Step 4 — Identify where your state should live" — react.dev/learn/thinking-in-react. El paso canónico de decidir dónde vive el estado, con el mismo criterio local vs levantado. En inglés.