Estado compartido: hacia arriba
En resumen: Subir el estado (lifting state up) lleva el estado compartido al padre común más cercano: los datos bajan como props y los cambios suben mediante un callback.
Problema
El contador de arriba no se entera
La tienda tiene arriba un contador del carrito y debajo una lista de productos. Añade pan, leche y otro pan.
Consola
La tarjeta del pan dice «En el carrito: 2», y arriba pone «Carrito (0)». El carrito vive en ProductList: ahí se pulsa el botón y ahí se puso el estado. CartBadge es otro componente, hermano de ProductList, y tiene su propio número que nadie cambia. Los dos componentes tienen dos carritos, y cada uno tiene razón con el suyo.
La idea
Hasta el padre común
Un componente ve solo su propio estado y las props que le pasa su padre. Un hermano no ve a su hermano. Así que dos sitios que muestran un carrito no pueden tener cada uno el suyo.
La solución es llevar el estado al padre común más cercano de los dos. Aquí es App. Esto se llama subir el estado (lifting state up):
Appguardaitemsy calculacount.- Los datos bajan como props:
countaCartBadge,itemsaProductList. - Los cambios suben como mensaje: el botón de
ProductCardno cambia el carrito, le dice aApp: «añade este producto».
export default function App() { const [items, setItems] = useState([]); const count = items.reduce((sum, i) => sum + i.qty, 0); function add(product) { /* cambia items */ } return ( <> <header><CartBadge count={count} /></header> <main><ProductList products={PRODUCTS} items={items} onAdd={add} /></main> </> );}onAdd={add} pasa la función hacia abajo. El hijo la llama cuando pasa algo: onClick={() => onAdd(product)}. Una función así se llama callback.
Ahora CartBadge y ProductList ya no tienen estado propio para el carrito. Muestran lo que les da App.
Míralo
Mira cómo sube
Al principio el carrito está en ProductList. Después sube a App y fluye desde allí hacia abajo.
function App() { const [items, setItems] = useState([]); const count = items.reduce((s, i) => s + i.qty, 0); function add(product) { setItems(plusOne(items, product)); } return ( <> <CartBadge count={count} /> <ProductList items={items} onAdd={add} /> </> );}
function ProductList({ items, onAdd }) { return PRODUCTS.map((p) => ( <ProductCard key={p.id} product={p} qty={qtyOf(items, p)} onAdd={onAdd} /> ));}
function ProductCard({ product, qty, onAdd }) { return <button onClick={() => onAdd(product)}>…</button>;}Paso a paso, en palabras
- CartBadge y ProductList son hermanos: los dos son hijos de App. Al principio el carrito vive en ProductList, y CartBadge tiene su propio contador.
- Un clic en «Añadir» llega a ProductList y la lista cuenta 1. CartBadge no ve ese estado: un hermano no lee el estado de su hermano. Se queda en 0.
- La solución: llevar el carrito al padre común más cercano de los dos, App. El contador deja su propia copia.
- Desde App los datos bajan como props: count a CartBadge, items a ProductList y qty a cada ProductCard. Los dos hermanos leen ahora un solo carrito.
- El hijo no es dueño del carrito, así que no puede cambiarlo. El clic llama a onAdd(product): una función que App pasó hacia abajo. La llamada sube hasta add() en App.
- App cambia items y las props nuevas bajan: el contador dice 2, la tarjeta dice 2. Nadie los actualizó a mano.
- La regla: el estado vive en un solo sitio, tan arriba como haga falta y no más. Los datos bajan como props, los cambios suben como eventos.
La dirección es la misma en cualquier app: datos hacia abajo, mensajes hacia arriba. Quien tiene los datos es el único que los cambia.
Pruébalo
Sube el carrito
El carrito está en ProductList, y CartBadge cuenta un número propio. Sube el carrito a App: CartBadge recibe la cantidad como prop, ProductList recibe items y avisa «Añadir» hacia arriba. La comprobación añade pan, leche y pan y mira las tarjetas y el contador.
Consola
Solución
App.jsximport { useState } from 'react';
import CartBadge from './CartBadge.jsx';
import ProductList from './ProductList.jsx';
const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 5, name: 'Leche fresca', price: 1.6 },
{ id: 7, name: 'Queso blanco', price: 7.9 },
];
export default function App() {
const [items, setItems] = useState([]);
const count = items.reduce((sum, i) => sum + i.qty, 0);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { ...product, qty: 1 }]);
}
return (
<>
<header>
<a href="/">La tienda</a>
<CartBadge count={count} />
</header>
<main>
<h1>Productos</h1>
<ProductList products={PRODUCTS} items={items} onAdd={add} />
</main>
</>
);
}
CartBadge.jsxexport default function CartBadge({ count }) {
return <a href="/cart">Carrito ({count})</a>;
}
ProductList.jsximport ProductCard from './ProductCard.jsx';
export default function ProductList({ products, items, onAdd }) {
const qtyOf = (id) => items.find((i) => i.id === id)?.qty ?? 0;
return (
<ul>
{products.map((p) => (
<ProductCard key={p.id} product={p} qty={qtyOf(p.id)} onAdd={onAdd} />
))}
</ul>
);
}
ProductCard.jsximport { money } from './money.js';
export default function ProductCard({ product, qty, onAdd }) {
return (
<li>
<span>{product.name}, {money(product.price)}</span>
{qty > 0 && <span>En el carrito: {qty}</span>}
<button type="button" aria-label={`Añadir ${product.name}`} onClick={() => onAdd(product)}>
Añadir
</button>
</li>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span:first-child { flex: 1; }
button { font: inherit; }
items, add y count suben a App. CartBadge recibe count={count}, ProductList recibe items={items} y onAdd={add} y le pasa onAdd a cada ProductCard. En ProductList ya no hay useState.
La comprobación también detecta onClick={onAdd(product)} en ProductCard. Sin la flecha, onAdd se llama ya durante el render. Cambia App, App vuelve a renderizar, la tarjeta llama otra vez a onAdd, y así sin fin. El contador crece solo sin un solo clic, y la consola dice Cannot update a component (`App`) while rendering a different component (`ProductCard`).
La idea
El hijo avisa, el padre decide
ProductCard no sabe cómo está construido el carrito. Solo sabe que alguien pulsó «Añadir» en su producto. Así que avisa de lo que pasó, no de lo que hay que cambiar: «añade este producto», no «sube qty en la línea 2». App decide qué significa eso: una línea nueva o una unidad más.
Esto deja la tarjeta más simple, y puede estar en cualquier sitio: en el catálogo, en «Recomendados», en la búsqueda. No es dueña de los datos y no los toca.
Los nombres siguen una convención: una prop con una función empieza por on y nombra el evento: onAdd, onRemove, onChange. La función del padre suele llamarse add o handleAdd. Pasas la función, no su resultado: onClick={() => onAdd(product)}, no onClick={onAdd(product)}.
Pruébalo
Rellena el hueco
¿Qué va en el hueco para que el clic llegue al padre?
export default function ProductCard({ product, onAdd }) {
return <button onClick={() => (product)}>Añadir</button>;
}onAdd: la función que el padre pasó como prop. La flecha la llama solo al hacer clic, con el producto de la tarjeta.Pruébalo
Encuentra el error
Elige la línea con el error.
onClick={onAdd(product)} llama a onAdd en cada render de la tarjeta y le da a onClick el resultado. Debería ser onClick={() => onAdd(product)}: una función que se llama al hacer clic.La idea
Cuando el árbol se hace profundo
¿Hasta dónde hay que subirlo? Tan arriba como haga falta, y no más. Si solo ProductCard usa algo (si la descripción está abierta), se queda en ella. Lo subes cuando un segundo componente tiene que verlo.
En una tienda real el carrito hace falta en muchos sitios: el contador de arriba, la lista, la página del producto, el pedido. El padre común está muy arriba, y el camino hasta ellos pasa por componentes que no usan el carrito, solo lo pasan hacia abajo. Esto se llama prop drilling. Con dos niveles es normal. Con cinco niveles, cada componente del camino tiene que saber de datos que no son suyos.
Para esto hay herramientas aparte: Context. Entregan los datos a descendientes lejanos sin pasar por los componentes del camino. Están en la lección sobre estado compartido del módulo «Efectos, datos y formularios». La regla sigue igual: los datos tienen un dueño, y los cambios pasan por él.
Más precisamente, para curiosos
Las props son de solo lectura. En modo de desarrollo, React congela el objeto de props: props.qty = 3 lanza TypeError: Cannot assign to read only property 'qty'. Un hijo que quiere un cambio avisa hacia arriba.
Por qué una flecha. onClick quiere una función que React llame más tarde. onClick={add} también es una función, pero React la llama con el evento del clic, no con el producto. Por eso onClick={() => onAdd(product)}: una función nueva que conoce el producto.
Los componentes de clase tienen el mismo modelo: el padre pasa onAdd como prop y el hijo llama a this.props.onAdd(product).
useState(items), la copia toma solo el primer valor y deja de seguir al padre. Es el error de la lección sobre estado derivado.Ejercicio
El carrito en el lateral
Junto a la lista hay una tercera parte, CartSummary: el encabezado «Tu carrito», líneas como «Pan blanco × 2» con un botón «Quitar», «Total: 4,00 €» y «El carrito está vacío» cuando está vacío. Ahora mismo CartSummary guarda un carrito propio que nadie llena. Dale el carrito de App y haz que «Quitar» quite de él. La comprobación añade tres productos, los quita y mira el contador, las tarjetas y el total.
Consola
Solución
App.jsximport { useState } from 'react';
import CartBadge from './CartBadge.jsx';
import ProductList from './ProductList.jsx';
import CartSummary from './CartSummary.jsx';
const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 5, name: 'Leche fresca', price: 1.6 },
{ id: 7, name: 'Queso blanco', price: 7.9 },
];
export default function App() {
const [items, setItems] = useState([]);
const count = items.reduce((sum, i) => sum + i.qty, 0);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { ...product, qty: 1 }]);
}
function remove(item) {
setItems(items.filter((i) => i.id !== item.id));
}
return (
<>
<header>
<a href="/">La tienda</a>
<CartBadge count={count} />
</header>
<main>
<h1>Productos</h1>
<ProductList products={PRODUCTS} items={items} onAdd={add} />
<CartSummary items={items} onRemove={remove} />
</main>
</>
);
}
CartBadge.jsxexport default function CartBadge({ count }) {
return <a href="/cart">Carrito ({count})</a>;
}
ProductList.jsximport ProductCard from './ProductCard.jsx';
export default function ProductList({ products, items, onAdd }) {
const qtyOf = (id) => items.find((i) => i.id === id)?.qty ?? 0;
return (
<ul>
{products.map((p) => (
<ProductCard key={p.id} product={p} qty={qtyOf(p.id)} onAdd={onAdd} />
))}
</ul>
);
}
ProductCard.jsximport { money } from './money.js';
export default function ProductCard({ product, qty, onAdd }) {
return (
<li>
<span>{product.name}, {money(product.price)}</span>
{qty > 0 && <span>En el carrito: {qty}</span>}
<button type="button" aria-label={`Añadir ${product.name}`} onClick={() => onAdd(product)}>
Añadir
</button>
</li>
);
}
CartSummary.jsximport { money } from './money.js';
export default function CartSummary({ items, onRemove }) {
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return (
<section aria-labelledby="cart-title">
<h2 id="cart-title">Tu carrito</h2>
{items.length === 0 && <p>El carrito está vacío</p>}
<ul>
{items.map((item) => (
<li key={item.id}>
<span>{item.name} × {item.qty}</span>
<button type="button" aria-label={`Quitar ${item.name}`} onClick={() => onRemove(item)}>
Quitar
</button>
</li>
))}
</ul>
<p>Total: {money(total)}</p>
</section>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span:first-child { flex: 1; }
button { font: inherit; }
App recibe una función remove(item) que filtra items. CartSummary recibe items={items} y onRemove={remove}, y el botón llama a onRemove(item). El total de CartSummary se calcula a partir de items. Ahora el contador, las tarjetas y el carrito del lateral leen un solo estado, así que quitar se ve en los tres sitios.
CartSummary (useState(props.items)): al crearse está vacía y no ve los productos añadidos después.Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Condiciones: vacío, error, cargando · Estado · Actualizar objetos y arrays · Estado derivado
- Continúa en: Estado compartido · Enrutamiento · Velocidad: cuándo se vuelve a renderizar un componente · Probar componentes
- Ejercicios sobre el tema: Estado