Estado compartido
En resumen: El estado compartido en React viene de un proveedor alto en el árbol con Context y useReducer, y los componentes lo leen directamente, sin props por cada nivel.
Problema
El contador vuelve a marcar 0
El carrito está elevado a App, como en la lección sobre elevar el estado. Desde ahí baja como props: a Header y desde él a CartBadge, a Catalog, desde él a ProductList y desde él a cada ProductCard. Alguien reescribió Header, y el contador se paró.
Consola
Añade pan, leche y otra vez pan. Las tarjetas cuentan, y la cabecera dice «Carrito (0)». App pasa count a Header, pero el nuevo Header no se lo pasa a CartBadge. Header no usa el carrito. Solo tenía que pasarlo, y justo eso olvidó.
La idea
Un proveedor arriba, consumidores abajo
Pasar datos por componentes que no los usan se llama prop drilling. Con dos niveles es normal. Con cinco, cada componente del camino depende del carrito, y una prop olvidada rompe la cadena.
Las dos bibliotecas tienen una forma de dar datos a descendientes lejanos sin los componentes del medio:
- El proveedor es el componente dueño de los datos. Dice: «todo lo que hay debajo de mí puede leer esto».
- El consumidor es el componente que los usa. Los toma directamente, sin importar cuántos niveles haya en medio.
En React esto es Context:
export const CartContext = createContext(null);
// App.jsx<CartContext value={{ items, count, add }}> <Header /> <main><Catalog /></main></CartContext>
// CartBadge.jsxconst { count } = useContext(CartContext);createContext(null) crea el «canal». null es el valor cuando no hay proveedor por encima del componente. <CartContext value={…}> es el proveedor. useContext(CartContext) lee el valor del proveedor más cercano hacia arriba en el árbol. Cuando el valor cambia, cada componente que lo lee se dibuja de nuevo.
Los datos siguen teniendo un dueño, y los cambios siguen pasando por él: add es la función del proveedor. Solo cambió el camino.
Míralo
Mira las dos formas
Al principio el carrito baja nivel por nivel, y la cadena se rompe por la mitad. Después App se convierte en proveedor y los dos consumidores leen directamente.
const CartContext = createContext(null);
function App() { const [items, setItems] = useState([]); const count = items.reduce((s, i) => s + i.qty, 0); function add(product) { /* … */ } return ( <CartContext value={{ items, count, add }}> <Header /> <Catalog /> </CartContext> );}
function Header() { return <header><CartBadge /></header>;}
function CartBadge() { const { count } = useContext(CartContext); return <a href="/cart">Carrito ({count})</a>;}
function ProductCard({ product }) { const { add } = useContext(CartContext); return <button onClick={() => add(product)}>Añadir</button>;}Paso a paso, en palabras
- El carrito vive en App. Dos componentes muy abajo lo necesitan: CartBadge en la cabecera y ProductCard en el catálogo.
- Solo con props, el carrito baja nivel por nivel. Header, Catalog y ProductList no lo usan. Solo lo pasan.
- Alguien reescribe Header y olvida count. La cadena se rompe por la mitad y el contador muestra 0. Eso es el prop drilling.
- En su lugar: App envuelve el árbol en <CartContext value={…}>. Todo lo que hay debajo puede leer el carrito.
- CartBadge y ProductCard lo leen con useContext(CartContext). Los componentes intermedios no saben nada del carrito.
- Un clic en «Añadir» llama a add(product). La función vino del proveedor, por eso el cambio ocurre en App.
- El carrito de App cambia y cada componente que lo lee se dibuja de nuevo: el contador dice 1, la tarjeta dice 1.
- Los datos siguen teniendo un solo dueño, App. Solo cambió el camino: no por cada nivel, sino directo desde el proveedor.
Los componentes del medio ya no saben nada del carrito. El nuevo Header lo puedes reescribir tantas veces como quieras.
Pruébalo
Quita el drilling
El carrito baja como props por Header y Catalog, y Header pierde count. Da el carrito desde arriba. Header, Catalog y ProductList no reciben nada sobre el carrito, y CartBadge y ProductCard lo leen directamente. La comprobación añade pan, leche y otra vez pan y mira las tarjetas y el contador.
Consola
Solución
App.jsximport { useState } from 'react';
import { CartContext } from './cart.js';
import Header from './Header.jsx';
import Catalog from './Catalog.jsx';
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 (
<CartContext value={{ items, count, add }}>
<Header />
<main>
<Catalog />
</main>
</CartContext>
);
}
Header.jsximport CartBadge from './CartBadge.jsx';
export default function Header() {
return (
<header>
<a href="/">La tienda</a>
<CartBadge />
</header>
);
}
CartBadge.jsximport { useContext } from 'react';
import { CartContext } from './cart.js';
export default function CartBadge() {
const { count } = useContext(CartContext);
return <a href="/cart">Carrito ({count})</a>;
}
Catalog.jsximport ProductList from './ProductList.jsx';
export default function Catalog() {
return (
<section>
<h1>Productos</h1>
<ProductList />
</section>
);
}
ProductList.jsximport ProductCard from './ProductCard.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 ProductList() {
return (
<ul>
{PRODUCTS.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
);
}
ProductCard.jsximport { useContext } from 'react';
import { CartContext } from './cart.js';
import { money } from './money.js';
export default function ProductCard({ product }) {
const { items, add } = useContext(CartContext);
const qty = items.find((i) => i.id === product.id)?.qty ?? 0;
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={() => add(product)}>
Añadir
</button>
</li>
);
}
cart.jsimport { createContext } from 'react';
export const CartContext = createContext(null);
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; }
CartContext ya está en cart.js. En App envuelve Header y main en <CartContext value={{ items, count, add }}>. CartBadge lee const { count } = useContext(CartContext), ProductCard lee items y add y calcula su propio qty. Quita las props del carrito de Catalog y ProductList.
La comprobación detecta un proveedor que envuelve solo main: Header se queda fuera, useContext devuelve null y CartBadge falla. También detecta un valor sin count.
La comprobación solo ve el comportamiento. Si vuelves a pasar count por Header, pasará. El objetivo aquí es otro: que el medio no sepa del carrito.
La idea
Un store para el carrito
El carrito crece: añadir, quitar, cambiar la cantidad, vaciarlo. Si cada acción es una función en App, App se llena de lógica que no le corresponde. Por eso el estado compartido se muda a un archivo aparte, un store: los datos y todo lo que los cambia, en un solo lugar.
React tiene para esto el hook incorporado useReducer. En lugar de setItems(…), los componentes envían una acción (dispatch({ type: 'add', product })), y una sola función, el reducer, decide qué pasa con el carrito:
function cartReducer(items, action) { switch (action.type) { case 'add': /* un array nuevo con el producto */ default: return items; }}
export function CartProvider({ children }) { const [items, dispatch] = useReducer(cartReducer, []); const add = (product) => dispatch({ type: 'add', product }); return <CartContext value={{ items, count, add }}>{children}</CartContext>;}CartProvider es el proveedor, y App solo lo pone alrededor del árbol. Los consumidores no cambian: siguen leyendo useContext(CartContext). El reducer es una función corriente: entran el carrito viejo y una acción, sale un carrito nuevo. No toca nada más, así que es fácil de probar.
Context y useReducer vienen incluidos en React. También hay bibliotecas para stores, por ejemplo Zustand y Redux Toolkit. No están en el sandbox, y aquí solo las mencionamos: la idea es la misma, una fuente y acciones que la cambian.
Consola
cart.jsx: ahí está toda la lógica del carrito. CartBadge y ProductCard son los mismos que en la tarea.Pruébalo
Encuentra el error
TypeError: Cannot destructure property 'count' of … as it is null. ¿Qué línea tiene la culpa?Elige la línea con el error.
<Header /> está fuera de <CartContext value={…}>. CartBadge, dentro de él, busca un proveedor hacia arriba y no lo encuentra, así que useContext devuelve el null de createContext(null). El proveedor debe envolver también Header.La idea
Cuándo compartir y cuándo no
El estado compartido es cómodo, y por eso es tentador meterlo todo ahí. Pero todo lo que hay en él se puede leer y cambiar desde cualquier sitio, y eso hace los errores más difíciles de encontrar. Sigue valiendo la regla de la lección sobre elevar: tan alto como haga falta, y no más.
- Compartido: lo que necesitan partes lejanas del árbol y lo que vive mientras el usuario está en el sitio. El carrito, el usuario con sesión iniciada, el idioma, el tema.
- En el componente: los campos de un formulario, si el menú está abierto, qué pestaña está elegida, el texto del cuadro de búsqueda. Lo necesita un componente (o su padre) y se va con él.
- Los datos del servidor no son estado compartido. La lista de productos y los pedidos tienen un dueño, y el dueño es el servidor. En un store son una copia que hay que refrescar. El componente que los muestra los carga, como en la lección sobre datos de una API.
El estado compartido no cambia el sentido: los datos parten de un dueño, y los cambios pasan por sus funciones.
Pruébalo
Rellena el hueco
CartBadge la cuenta del proveedor?import { useContext } from 'react';
import { CartContext } from './cart.js';
export default function CartBadge() {
const { count } = (CartContext);
return <a href="/cart">Carrito ({count})</a>;
}useContext: lee el valor del <CartContext value={…}> más cercano por encima del componente y lo dibuja de nuevo cuando ese valor cambia.Más precisamente, para curiosos
<CartContext.Provider>. Antes de React 19 el proveedor se escribía <CartContext.Provider value={…}>. Esa forma sigue funcionando, y <CartContext value={…}> es la nueva, más corta.
Tu propio hook. Junto a un contexto suele haber una pequeña función export function useCart() { return useContext(CartContext); }. También puede lanzar un error claro cuando no hay proveedor, en lugar de un TypeError sobre null.
Quién se dibuja de nuevo. Cada componente que lee el contexto se dibuja de nuevo cuando cambia su valor. value={{ items, count, add }} es un objeto nuevo en cada dibujado del proveedor. En una aplicación grande esto se protege con useMemo o con varios contextos más pequeños. En la lección sobre velocidad verás cómo medirlo.
Los componentes de clase leen un contexto con static contextType o con <CartContext.Consumer>. Los encontrarás en código antiguo.
El store vive mientras la página está abierta. Al recargar se pierde. Un carrito que debe sobrevivir se guarda en localStorage o en el servidor.
Las comprobaciones ven el comportamiento. Las tareas de aquí no ven si el carrito viene de props, de un contexto o de un store. Comprueban que el contador y las tarjetas muestren lo mismo.
Ejercicio
La página del carrito
En la parte principal está CartSummary: un encabezado El carrito, filas Pan blanco × 2 con un botón Quitar, Total: 4,00 €, y El carrito está vacío cuando está vacío. Ahora tiene su propio carrito que nadie rellena. Añade al store el quitar y un total, y haz que CartSummary lea el carrito de ahí. La comprobación añade tres productos, los quita y mira el contador, las tarjetas y el total.
Consola
Solución
App.jsximport { CartProvider } from './cart.jsx';
import Header from './Header.jsx';
import Catalog from './Catalog.jsx';
import CartSummary from './CartSummary.jsx';
export default function App() {
return (
<CartProvider>
<Header />
<main>
<Catalog />
<CartSummary />
</main>
</CartProvider>
);
}
cart.jsximport { createContext, useReducer } from 'react';
export const CartContext = createContext(null);
function cartReducer(items, action) {
switch (action.type) {
case 'add': {
const found = items.find((i) => i.id === action.product.id);
if (found) return items.map((i) => (i.id === found.id ? { ...i, qty: i.qty + 1 } : i));
return [...items, { ...action.product, qty: 1 }];
}
case 'remove':
return items.filter((i) => i.id !== action.id);
default:
return items;
}
}
export function CartProvider({ children }) {
const [items, dispatch] = useReducer(cartReducer, []);
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const add = (product) => dispatch({ type: 'add', product });
const remove = (id) => dispatch({ type: 'remove', id });
return <CartContext value={{ items, count, total, add, remove }}>{children}</CartContext>;
}
Header.jsximport CartBadge from './CartBadge.jsx';
export default function Header() {
return (
<header>
<a href="/">La tienda</a>
<CartBadge />
</header>
);
}
CartBadge.jsximport { useContext } from 'react';
import { CartContext } from './cart.jsx';
export default function CartBadge() {
const { count } = useContext(CartContext);
return <a href="/cart">Carrito ({count})</a>;
}
Catalog.jsximport ProductList from './ProductList.jsx';
export default function Catalog() {
return (
<section>
<h1>Productos</h1>
<ProductList />
</section>
);
}
ProductList.jsximport ProductCard from './ProductCard.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 ProductList() {
return (
<ul>
{PRODUCTS.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
);
}
ProductCard.jsximport { useContext } from 'react';
import { CartContext } from './cart.jsx';
import { money } from './money.js';
export default function ProductCard({ product }) {
const { items, add } = useContext(CartContext);
const qty = items.find((i) => i.id === product.id)?.qty ?? 0;
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={() => add(product)}>
Añadir
</button>
</li>
);
}
CartSummary.jsximport { useContext } from 'react';
import { CartContext } from './cart.jsx';
import { money } from './money.js';
export default function CartSummary() {
const { items, total, remove } = useContext(CartContext);
return (
<section aria-labelledby="cart-title">
<h2 id="cart-title">El 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={() => remove(item.id)}>
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; }
En cart.jsx añade una acción case 'remove': return items.filter((i) => i.id !== action.id);, una función remove = (id) => dispatch({ type: 'remove', id }) y total, y ponlas en value. CartSummary lee const { items, total, remove } = useContext(CartContext) y llama a remove(item.id).
La comprobación detecta un reducer que quita con splice y devuelve el mismo array: React no ve ningún cambio y no se dibuja nada. También detecta un CartSummary colocado fuera de CartProvider.
La comprobación detecta también una copia del carrito en CartSummary: está vacía al crearse y no sigue al store.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Estado derivado · Estado compartido: hacia arriba · Datos de una API en un componente · Formularios en un componente
- Continúa en: Enrutamiento · Del sandbox al proyecto · El escaparate de la tienda
- Ejercicios sobre el tema: Efectos, datos y formularios