Módulo 7: Lifting State And Composition

Levantar el estado al ancestro común

Descripción

La lección 1 te mostró el problema y le puso nombre: cuando dos componentes hermanos necesitan el mismo dato, no puede vivir en ninguno de los dos, porque un componente no lee el estado de su hermano. Esta lección toma esa idea y la convierte en una técnica de tres pasos que aplicarás una y otra vez: encontrar el ancestro común, mover el estado ahí, y bajarlo por props —el dato a quien lo muestra, el callback a quien lo cambia—. La llamamos levantar el estado (lifting state up), y es una de las maniobras más frecuentes en React. Aquí la vas a ver no solo explicada, sino medida: ejecutamos dos hermanos con estado local separado y comprobamos que se desincronizan (imprime false), y luego el mismo caso con el estado levantado, que se mantiene consistente (imprime true).

Conexión con el módulo. Es la técnica central del módulo, la que da nombre a todo. La lección 1 planteó el problema del carrito compartido; esta te da el procedimiento exacto para resolverlo. Usa lo del módulo 4 (los datos suben por callbacks) al revés de como lo viste: allá el dato subía y asumíamos que el padre "ya tenía" el estado; aquí diseñamos ese estado del padre. Y prepara la lección 3, que pone el criterio (no todo se levanta) y la lección 4, que le da forma al estado del carrito con un reducer.

Una analogía: la pizarra compartida de la oficina

Imagina un equipo en una oficina que lleva la cuenta de cuántos pedidos van del día. Otra vez, hay dos diseños, y solo uno no se rompe.

El diseño malo: cada quien anota la cuenta en su propio cuaderno. Ana, en su cuaderno, escribe "3 pedidos". Beto, en el suyo, tiene "1 pedido" porque no vio los que anotó Ana. Cuando el jefe pregunta "¿cuántos pedidos llevamos?", cada quien contesta un número distinto. Los cuadernos son privados —Beto no puede leer el de Ana—, así que las cuentas divergen apenas alguien anota algo. Eso son dos componentes hermanos con estado local: cada uno con su cuaderno, contradiciéndose.

El diseño bueno: una sola pizarra colgada en la pared, a la vista de todos. Cuando Ana registra un pedido, lo escribe en la pizarra; cuando Beto quiere saber la cuenta, la lee de la pizarra. Hay un solo número, en un solo lugar, y todos leen y escriben ahí. Nadie tiene una versión privada que pueda contradecir a las demás. Esa pizarra es el estado levantado: no vive en el cuaderno de ningún miembro, sino en un lugar común que todos comparten —el ancestro que está arriba de todos ellos—.

En React, los cuadernos privados son el estado local de cada hermano (useState dentro del ProductList, otro dentro del Cart). La pizarra en la pared es el estado levantado al ancestro común (el App). Y la mecánica de "escribir en la pizarra" es un callback que el ancestro presta a los hijos: el hijo no escribe directo (no alcanza la pizarra del otro), sino que le pide al dueño de la pizarra que anote, invocando la función que le bajó por props. El dato baja de la pizarra a quien lo lee; las órdenes de "anota esto" suben a la pizarra. Una fuente, muchos que la usan.

Ejemplo trabajado: el estado local que se desincroniza, y el levantado que no

Vamos a medir el contraste. Primero montamos el diseño malo: dos hermanos, cada uno con su copia local del carrito. El ProductList agrega a su copia; el Cart muestra la suya. Comprobamos si concuerdan. Luego montamos el diseño bueno: un solo carrito levantado en el App, que el ProductList cambia por un callback y el Cart lee. Comprobamos de nuevo.

Antes, cómo se ve el diseño bueno en React de verdad. El estado sube al App, y baja: los items al Cart, el callback al ProductList:

function App() {
  const [cart, setCart] = useState([]);            // la PIZARRA: vive en el App

  function handleAddToCart(product) {              // "escribir en la pizarra"
    setCart((prev) => [...prev, product]);         // inmutable (modulo 3)
  }

  return (
    <div className="app">
      {/* al ProductList le baja la forma de AGREGAR (no el carrito entero) */}
      <ProductList products={PRODUCTS} onAddToCart={handleAddToCart} />
      {/* al Cart hermano le bajan los ITEMS para mostrarlos */}
      <Cart items={cart} />
    </div>
  );
}

Fíjate: ni ProductList ni Cart tienen useState del carrito. El App lo tiene, y baja lo justo: al que agrega, la función; al que muestra, los datos. Ahora ejecutemos los dos diseños y midamos:

// Demostracion: dos hermanos con estado LOCAL separado se desincronizan;
// el estado LEVANTADO en App los mantiene consistentes.
const MOUSE    = { id: 'p1', name: 'Wireless Mouse',      priceCents: 2599 };
const KEYBOARD = { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 };

console.log('=== PROBLEMA: cada hermano guarda SU propio carrito ===\n');
let productListCart = []; // estado local del hermano que agrega
let cartViewCart = [];    // estado local del hermano que muestra

productListCart = [...productListCart, MOUSE];
console.log('El usuario agrega el Mouse (desde ProductList):');
console.log(`  ProductList cree que el carrito tiene: ${productListCart.length} item(s)`);
console.log(`  Cart muestra al usuario:               ${cartViewCart.length} item(s)`);
console.log(`  consistentes? ${productListCart.length === cartViewCart.length}`);

productListCart = [...productListCart, KEYBOARD];
console.log('\nEl usuario agrega el Keyboard (desde ProductList):');
console.log(`  ProductList cree que el carrito tiene: ${productListCart.length} item(s)`);
console.log(`  Cart muestra al usuario:               ${cartViewCart.length} item(s)`);
console.log(`  consistentes? ${productListCart.length === cartViewCart.length}`);

console.log('\n=== SOLUCION: el estado LEVANTADO en App, uno solo ===\n');
let appCart = []; // UNA sola fuente de verdad, en el ancestro comun
function onAddToCart(product) { appCart = [...appCart, product]; } // ProductList avisa
function productListView() { return `${appCart.length} item(s)`; } // lee appCart
function cartView() { return `${appCart.length} item(s)`; }        // lee el MISMO appCart

onAddToCart(MOUSE);
console.log('El usuario agrega el Mouse (ProductList avisa al App):');
console.log(`  ProductList ve: ${productListView()}`);
console.log(`  Cart ve:        ${cartView()}`);
console.log(`  consistentes? ${productListView() === cartView()}`);

onAddToCart(KEYBOARD);
console.log('\nEl usuario agrega el Keyboard (ProductList avisa al App):');
console.log(`  ProductList ve: ${productListView()}`);
console.log(`  Cart ve:        ${cartView()}`);
console.log(`  consistentes? ${productListView() === cartView()}`);

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

=== PROBLEMA: cada hermano guarda SU propio carrito ===

El usuario agrega el Mouse (desde ProductList):
  ProductList cree que el carrito tiene: 1 item(s)
  Cart muestra al usuario:               0 item(s)
  consistentes? false

El usuario agrega el Keyboard (desde ProductList):
  ProductList cree que el carrito tiene: 2 item(s)
  Cart muestra al usuario:               0 item(s)
  consistentes? false

=== SOLUCION: el estado LEVANTADO en App, uno solo ===

El usuario agrega el Mouse (ProductList avisa al App):
  ProductList ve: 1 item(s)
  Cart ve:        1 item(s)
  consistentes? true

El usuario agrega el Keyboard (ProductList avisa al App):
  ProductList ve: 2 item(s)
  Cart ve:        2 item(s)
  consistentes? true

Lee la salida como la prueba que es. En el problema, el ProductList agrega a su copia local y sube su cuenta a 1, luego a 2; pero el Cart —que tiene otra copia, que nadie tocó— sigue en 0. La comprobación imprime false las dos veces: los hermanos se contradicen. Es el usuario viendo un botón "Add" que aparentemente funciona y un carrito que se queda vacío. Dos cuadernos privados que no coinciden.

En la solución, hay un solo appCart. El ProductList no guarda nada propio: llama a onAddToCart(product), que escribe en la pizarra común. Y tanto la vista del ProductList como la del Cart leen ese mismo appCart. Por eso, al agregar el mouse, los dos ven 1; al agregar el keyboard, los dos ven 2. La comprobación imprime true las dos veces: consistentes, siempre. Un solo número, en un solo lugar, muchos lectores.

La diferencia entre false y true es la lección entera. No es que el segundo diseño "tenga más código correcto"; es que eliminó la posibilidad de contradecirse al tener una sola fuente de verdad. Cuando dos partes muestran el mismo dato, la única forma de que nunca discrepen es que sea el mismo dato, no dos copias.

Profundización: los tres pasos de levantar el estado

Levantar el estado no es una corazonada: es un procedimiento con tres pasos claros. Cuando notes que dos componentes necesitan el mismo dato, haz esto.

Paso 1 — Encuentra el ancestro común más cercano. Mira el árbol y localiza el componente que está arriba de los dos que comparten el dato. En Mercado, ProductList y Cart cuelgan ambos del App; su ancestro común más cercano es el App. "Más cercano" importa: no subas hasta el tope si hay un ancestro común más abajo que ya los cubre a ambos. El estado debe vivir tan arriba como sea necesario, pero no más.

flowchart TD
    App["App  ← el ancestro comun (aqui vive el estado)"]
    PL[ProductList]
    Cart[Cart]
    PC[ProductCard]
    CI[CartItem]
    App --> PL
    App --> Cart
    PL --> PC
    Cart --> CI

Paso 2 — Mueve el estado ahí. Quita el useState (o useReducer) de los hijos y ponlo en el ancestro común. Si el ProductList tenía const [cart, setCart] = useState([]), ese useState ahora vive en el App. El estado dejó de ser privado de un hermano y pasó a ser del ancestro que ambos comparten —la pizarra colgó en la pared común—.

Paso 3 — Baja el dato por props (y el callback). Desde el ancestro, pasa a cada hijo exactamente lo que necesita, y no más:

  • A quien muestra el dato, bájale el dato: <Cart items={cart} />.
  • A quien cambia el dato, bájale un callback para pedir el cambio: <ProductList onAddToCart={handleAddToCart} />.

Un hijo puede recibir las dos cosas si las dos le hacen falta (el Cart recibe items para mostrar y onRemoveFromCart para quitar). La regla del reparto es la del módulo 4: los datos bajan por props; las órdenes de cambio suben por callbacks. El dueño del estado —el ancestro— es el único que lo modifica (con su setter o dispatch); los hijos solo lo leen o lo piden.

Por qué esto funciona y el estado local no. El estado local de un componente es re-creado y aislado por componente: dos hermanos con useState([]) tienen dos celdas de memoria distintas, sin ninguna conexión. Cambiar una no toca la otra. Al levantar, hay una sola celda en el ancestro, y lo que baja a los hijos son vistas de esa única celda. No hay dos verdades porque no hay dos celdas. Esa es, técnicamente, la razón por la que el diseño levantado imprimió true y el local imprimió false.

El costo justo, y por qué se paga. Levantar tiene un precio: el ancestro carga con estado que, individualmente, cada hijo "sentiría" más cerca si lo tuviera él. Se paga ese precio solo cuando el dato se comparte, porque a cambio se gana la consistencia (imposible desincronizarse) y un solo lugar donde entender y cambiar la lógica. Cuándo vale la pena pagarlo y cuándo no —el criterio local vs levantado— es justo la lección 3.

Errores comunes

Dejar una copia local "de más" además del estado levantado. Qué pasa: levantas el carrito al App, pero el Cart sigue teniendo su propio useState que "copia" los items al recibirlos por props, y a partir de ahí muestra su copia. Ahora tienes dos fuentes de nuevo, y se vuelven a desincronizar. Por qué pasa: la costumbre de "guardar en estado lo que llega". Cómo detectarlo: el Cart no se actualiza cuando el carrito del App cambia, porque muestra su copia congelada. Cómo corregirlo: si un dato llega por props, muéstralo directo desde las props; no lo copies a estado. El estado vive en un solo lugar (el ancestro); los demás lo reciben y lo usan tal cual. (Esto conecta con "derivar, no guardar", del módulo 5.)

Bajar más de lo necesario (el estado entero a quien solo lo cambia). Qué pasa: al ProductList, que solo necesita agregar, le bajas el carrito completo además del callback. Ahora el ProductList conoce el carrito sin razón, y podría sentirse tentado a leerlo o "arreglarlo" localmente. Por qué pasa: se baja "todo por si acaso". Cómo detectarlo: un hijo recibe props que nunca usa. Cómo corregirlo: baja lo justo —al que agrega, solo el callback; al que muestra, solo los items—. Cada componente recibe la mínima información que necesita para su trabajo; eso lo mantiene simple y reutilizable.

Que el hijo intente modificar el estado del ancestro directamente. Qué pasa: dentro del ProductList, alguien intenta props.cart.push(product) o reasignar algo que llegó por props, en vez de llamar al callback. Por qué pasa: se olvida que las props son de solo lectura (módulo 1) y se busca el atajo. Cómo detectarlo: mutas algo que llegó por props y la UI no se actualiza, o React se queja. Cómo corregirlo: el hijo no toca el estado del ancestro; lo pide invocando el callback que le bajó (onAddToCart(product)), y el ancestro —dueño del estado— lo cambia con su setter. Hijo pide, ancestro decide. (Es el patrón de callbacks del módulo 4, ahora con el estado bien ubicado.)

Ejercicios

Ejercicio 1 — Los tres pasos, aplicados. Tienes un SearchBar (donde el usuario teclea) y un ProductList (que debe filtrarse según lo tecleado), ambos hijos del App. Hoy el query vive dentro del SearchBar y la lista no se filtra. Aplica los tres pasos de levantar el estado: (1) ¿cuál es el ancestro común?; (2) ¿dónde va ahora el useState del query?; (3) ¿qué baja a cada hijo?

Ver solución
  • Paso 1 — Ancestro común: el App. Tanto SearchBar como ProductList cuelgan de él, y es quien está arriba de ambos.
  • Paso 2 — Mover el estado: el const [query, setQuery] = useState('') sale del SearchBar y se pone en el App.
  • Paso 3 — Bajar por props:
    • Al SearchBar (que cambia el query): le baja el valor actual para mostrarlo y un callback para cambiarlo → <SearchBar query={query} onQueryChange={setQuery} /> (input controlado, módulo 4).
    • Al ProductList (que se filtra con el query): le baja el query (o mejor, la lista ya filtrada, derivándola en el App) → <ProductList products={visibleProducts} />.

Con el query levantado al App, tanto la barra como la lista leen del mismo valor, y la lista por fin reacciona a lo que el usuario teclea. Es el mismo patrón del carrito, aplicado a la búsqueda.

Ejercicio 2 — Baja lo justo. El App tiene el carrito levantado. Para cada hijo, di qué props debe bajarle el App (dato, callback, o ambos), y por qué: (a) Cart, que muestra los items y tiene un botón "Remove" por item; (b) ProductList, cuyos ProductCard tienen "Add"; (c) un CartBadge en el header que solo muestra cuántos items hay.

Ver solución
  • (a) Cart: dato + callback. Muestra los items (necesita items={cart}) y los quita (necesita onRemoveFromCart). Recibe las dos cosas porque hace las dos.
  • (b) ProductList: solo callback. Solo agrega; no muestra el carrito. Le basta onAddToCart. Bajarle el carrito entero sería de más (no lo usa).
  • (c) CartBadge: solo dato. Solo muestra el conteo; no lo cambia. Le basta el dato para mostrar —por ejemplo, count={cart.length} (derivado) o items={cart} y que él calcule el largo—. No necesita ningún callback.

La regla: baja el dato a quien lo muestra, el callback a quien lo cambia, ambos a quien hace las dos, y nada de más. Cada hijo recibe la mínima información para su trabajo.

Ejercicio 3 — Traza el arreglo. Retoma el caso del ejercicio 2 de la lección 1 (un compañero puso el carrito en el useState del ProductList y otro en el del Cart, y se desincronizan). Escribe, en pasos, cómo lo arreglas con la técnica de esta lección, y qué imprimiría la comprobación de consistencia después del arreglo.

Ver solución

El arreglo son los tres pasos:

  1. Ancestro común: el App (arriba de ProductList y Cart).
  2. Mover el estado: quitar los dos useState([]) locales (el del ProductList y el del Cart) y poner uno solo en el App: const [cart, setCart] = useState([]).
  3. Bajar por props: <ProductList onAddToCart={handleAddToCart} /> (callback para agregar) y <Cart items={cart} onRemoveFromCart={handleRemove} /> (dato para mostrar + callback para quitar). El handleAddToCart del App hace setCart((prev) => [...prev, product]) (inmutable).

Después del arreglo, hay una sola copia del carrito, y los dos hermanos leen de ella. La comprobación de consistencia imprimiría true en cada paso, igual que la parte "SOLUCIÓN" del ejemplo trabajado: agregar desde cualquier lado se refleja en ambos, porque miran el mismo cart.

Resumen y siguiente paso

En esta lección convertiste "levantar el estado" en una técnica de tres pasos: encontrar el ancestro común más cercano, mover el estado ahí, y bajar por props —el dato a quien lo muestra, el callback a quien lo cambia—. La anclaste con la pizarra compartida: una sola pizarra en la pared que todos leen y escriben, en vez de un cuaderno privado por persona que se contradicen. Y la mediste: dos hermanos con estado local separado imprimieron false (desincronizados) en cada paso, mientras que el estado levantado imprimió true (consistentes) siempre, porque no había dos celdas de memoria sino una sola, leída por ambos.

Antes de avanzar deberías poder: ejecutar los tres pasos de levantar el estado sobre un caso nuevo; explicar técnicamente por qué el estado local duplicado se desincroniza (dos celdas) y el levantado no (una celda); y bajar a cada hijo lo justo —dato, callback, o ambos—.

La lección 3 pone el criterio que le falta a esta técnica: si levantar arregla la desincronización, ¿por qué no levantar todo al App? Vas a ver, ejecutado, la otra cara —estado que debe quedarse local porque solo un componente lo usa (y que se mantiene independiente por componente), frente al carrito que sí se comparte— y el error de levantar de más: el estado global prematuro que acopla y complica. La regla que sale de ahí es corta y la usarás siempre: local por defecto, sube solo cuando se comparte.

Recursos