Estado derivado
En resumen: El estado derivado es un valor que se calcula a partir del estado durante el render (una cantidad, un total, una lista filtrada) en lugar de guardarse aparte; para un cálculo costoso, useMemo guarda el resultado entre renders.
Problema
El total se queda atrás
El carrito tiene pan blanco y leche fresca. Pulsa «+» en el pan: la cantidad y el total crecen. Ahora quita la leche.
Consola
La línea desaparece, la cantidad baja a 3, y el total se queda en 5,20 €. El componente guarda tres estados: items, count y total. Cada clic tiene que cambiar los tres, y la función de quitar cambia dos. Es la misma deriva que en la lección «Una lista a partir de datos», solo que dentro de un componente. La biblioteca dibuja exactamente lo que le das. Le das dos verdades sobre un mismo carrito.
La idea
Calcúlalo, no lo guardes
Pregunta por cada pieza de estado: ¿puedo calcularla a partir del otro estado o de las props? Si puedes, no es estado. La cantidad es la suma de las cantidades de items. El total es la suma de precio por cantidad. Las dos se calculan.
Un valor así se llama estado derivado (derived state). Guardas solo items y calculas el resto a partir de él cada vez que el componente se renderiza.
const [items, setItems] = useState(START);const count = items.reduce((sum, i) => sum + i.qty, 0);const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);setItems hace que React ejecute otra vez la función App. Entonces count y total se calculan a partir de los items nuevos. Son constantes normales. No necesitan useState ni nada especial.
Ahora las funciones more y remove cambian solo items. Ya no queda nada que puedan olvidar.
Míralo
Mira los valores calculados y la copia
A la izquierda están el carrito y una copia del total, guardada aparte. A la derecha están los tres valores calculados.
const [items, setItems] = useState(START);const [savedTotal, setSavedTotal] = useState(26.9);
const count = items.reduce((s, i) => s + i.qty, 0);const total = items.reduce((s, i) => s + i.price * i.qty, 0);const free = total >= 30;
function more(item) { setItems(items.map((i) => i.id === item.id ? { ...i, qty: i.qty + 1 } : i, )); setSavedTotal(savedTotal + item.price);}
function remove(item) { setItems(items.filter((i) => i.id !== item.id));}Paso a paso, en palabras
- A la izquierda está el estado: los elementos del carrito items. Debajo hay un segundo estado, savedTotal: el mismo total, guardado como copia.
- A la derecha están count, total y free: constantes normales. React ejecuta App en cada render, así que se calculan de nuevo a partir de items cada vez: 26,90 €, faltan 3,10 € para el envío gratis.
- more(queso): items cambia y las tres flechas se encienden. El total es 34,80 €, el envío es gratis. more() también actualiza savedTotal a mano, así que la copia todavía es correcta.
- remove(queso): items cambia, y count, total y free lo siguen solos: 3,20 €. Para savedTotal en remove() nadie escribió una línea.
- La copia dice 34,80 €, el carrito es 3,20 €. Ningún error, solo un número falso: el estado y su copia se han separado.
- La regla: guarda solo lo que no se puede calcular. Lo que se deduce del estado se calcula a partir de él, y no hay ninguna copia que olvidar.
Los valores calculados siguen al carrito en cada cambio, porque no tienen memoria propia. La copia lo sigue solo mientras alguien se acuerde de actualizarla. La primera función que se olvide la deja mal.
Pruébalo
Calcula la cantidad y el total
El carrito guarda count y total como estados aparte, y remove() se olvida del total. Quita los dos estados y calcula la cantidad y el total a partir de items. La comprobación pulsa «+» en el pan, luego quita la leche y mira la cantidad y el total.
Consola
Solución
App.jsximport { useState } from 'react';
import { money } from './money.js';
const START = [
{ id: 1, name: 'Pan blanco', price: 1.2, qty: 2 },
{ id: 5, name: 'Leche fresca', price: 1.6, qty: 1 },
];
export default function App() {
const [items, setItems] = useState(START);
// Cantidad y total: calculados en cada render.
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
function more(item) {
setItems(items.map((i) => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i)));
}
function remove(item) {
setItems(items.filter((i) => i.id !== item.id));
}
return (
<main>
<h1>Tu carrito</h1>
<ul>
{items.map((item) => (
<li key={item.id}>
<span>{item.name} × {item.qty}</span>
<button type="button" aria-label={`Más ${item.name}`} onClick={() => more(item)}>+</button>
<button type="button" aria-label={`Quitar ${item.name}`} onClick={() => remove(item)}>Quitar</button>
</li>
))}
</ul>
<p>Artículos: {count}</p>
<p>Total: {money(total)}</p>
</main>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button { font: inherit; }
button[aria-pressed="true"] { background: #2f6f4f; color: white; }
Solo queda items. La cantidad es items.reduce((sum, i) => sum + i.qty, 0), el total es lo mismo con i.price * i.qty. En React son constantes en el cuerpo de App: se calculan en cada render. more y remove ahora cambian solo el carrito.
Una trampa: const [total] = useState(items.reduce(…)). El valor inicial de useState se toma solo en el primer render, así que el total se queda en 4,00 € para siempre.
Pruébalo
Rellena el hueco
La cantidad se calcula a partir del carrito. ¿Qué va en el hueco?
const [items, setItems] = useState(START);
count = items.reduce((sum, i) => sum + i.qty, 0);const: una constante normal en el cuerpo del componente. Se calcula de nuevo en cada render, mientras que useState aquí recordaría solo el primer valor.La idea
Cuándo guardar el resultado
La constante se calcula en cada render del componente, incluso cuando items es el mismo y el render viene de otro estado. Con un total de diez líneas eso no es nada. Si el cálculo es costoso (ordenar miles de productos), React tiene useMemo:
const sorted = useMemo(() => products.toSorted(byPrice), [products]);React guarda el resultado y ejecuta la función otra vez solo si alguna de las dependencias del array ([products]) es distinta de las del render anterior. Si olvidas una dependencia, el resultado se queda viejo. Por eso useMemo no es para cualquier total. Empieza con una constante y añádelo cuando midas que el cálculo es lento. El resultado es el mismo, solo se calcula con menos frecuencia.
La idea
El filtro también se calcula
El catálogo tiene los botones «Todos», «Pan» y «Leche». La tentación es guardar dos arrays: todos los productos y los visibles. Pero los visibles se deducen de otros dos datos: la lista completa y la categoría elegida. Guardas solo la categoría:
const [category, setCategory] = useState('all');const visible = category === 'all' ? PRODUCTS : PRODUCTS.filter((p) => p.category === category);Con la ordenación pasa igual: guardas «por precio» o «por nombre», y la lista ordenada se calcula. Ojo con sort(): ordena el array en el sitio, es decir, cambia el estado. toSorted() devuelve un array nuevo y deja el viejo.
Pruébalo
Solo la categoría
Los botones cambian la categoría, pero la lista siempre muestra todo. Calcula los productos visibles a partir de la categoría y muestra «Mostrados: 3 de 5» encima de la lista. La comprobación elige «Leche», luego «Pan», luego «Todos».
Consola
Solución
App.jsximport { useState } from 'react';
import ProductCard from './ProductCard.jsx';
const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2, category: 'bread' },
{ id: 2, name: 'Pan integral', price: 2.1, category: 'bread' },
{ id: 5, name: 'Leche fresca', price: 1.6, category: 'milk' },
{ id: 6, name: 'Yogur', price: 0.9, category: 'milk' },
{ id: 7, name: 'Queso blanco', price: 7.9, category: 'milk' },
];
const CATEGORIES = [
{ id: 'all', name: 'Todos' },
{ id: 'bread', name: 'Pan' },
{ id: 'milk', name: 'Leche' },
];
export default function App() {
const [category, setCategory] = useState('all');
const visible = category === 'all' ? PRODUCTS : PRODUCTS.filter((p) => p.category === category);
return (
<main>
<h1>Productos</h1>
{CATEGORIES.map((c) => (
<button key={c.id} type="button" aria-pressed={category === c.id} onClick={() => setCategory(c.id)}>
{c.name}
</button>
))}
<p>Mostrados: {visible.length} de {PRODUCTS.length}</p>
<ul>
{visible.map((p) => <ProductCard key={p.id} product={p} />)}
</ul>
</main>
);
}
ProductCard.jsximport { money } from './money.js';
export default function ProductCard({ product }) {
return (
<li>
<span>{product.name}</span> {money(product.price)}
</li>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button { font: inherit; }
button[aria-pressed="true"] { background: #2f6f4f; color: white; }
visible se calcula a partir de category y PRODUCTS: para «Todos» es la lista entera, y si no, un filter por categoría. La cantidad es visible.length de PRODUCTS.length.
Si visible es un segundo estado, el código suele acabar así: setCategory(id) y justo después setVisible(… category …). Pero category en este clic todavía es el valor viejo: el nuevo llega con el siguiente render. La lista va un clic por detrás. Con un valor calculado este problema no existe.
La idea
Una copia de una prop
El mismo error ocurre entre componentes. App da un 20 % de descuento y pasa los precios nuevos a cada ProductCard. La tarjeta, sin embargo, recuerda el precio que recibió la primera vez:
Consola
Pulsa «20 % de descuento». El botón queda pulsado, y el pan sigue costando 1,20 €.
const [price] = useState(product.price) toma product.price solo en el primer render de la tarjeta. Después React renderiza la tarjeta con el product nuevo, pero su estado es suyo y se queda.
La solución es no copiar: muestra product.price directamente. Si la tarjeta tiene que mostrar algo calculado a partir de una prop (el precio con IVA, el precio por unidad), calcúlalo durante el render.
Pruébalo
Encuentra el error
La compradora pulsa «+»: la cantidad de la línea pasa a 3, y el total de la línea se queda como con 2. ¿Qué línea tiene la culpa?
Elige la línea con el error.
item.price * item.qty al estado. useState toma el valor inicial una vez, así que el total de la línea no sigue a la cantidad. Debería ser const subtotal = item.price * item.qty;.Más precisamente, para curiosos
Cuando la copia es a propósito. A veces quieres un valor de fuera solo como punto de partida: un formulario de edición empieza con el nombre actual, y luego la persona escribe. Entonces la copia es correcta, pero dilo en el nombre: la prop initialName, el estado name. Quien lee el código ve que no se siguen los cambios de fuera.
useMemo es una pista. React se reserva el derecho de tirar el resultado recordado y calcular de nuevo, y la documentación lo dice explícitamente. Así que el código tiene que ser correcto sin useMemo. React Compiler, si el proyecto lo usa, recuerda por sí solo esos cálculos, y un useMemo manual hace falta aún menos.
Los renders son frecuentes. Un componente se vuelve a renderizar cuando cambia su estado o cuando se renderiza su padre. Las constantes de dentro se calculan cada vez. Por eso el cuerpo de un componente no lleva fetch, temporizadores ni cambios de estado: eso es para los efectos, el módulo siguiente.
Lo mismo vale para los datos del servidor. Si el servidor devuelve los productos y guardas «número de productos» aparte, los dos pueden separarse después de una carga nueva. Guarda la respuesta y calcula la cantidad.
Ejercicio
Envío gratis
El carrito tiene botones «−» y «+» en cada línea («−» en la última unidad quita la línea). Debajo de la lista muestra «Artículos: 4», «Total: 18,20 €» y «Para el envío gratis faltan 11,80 €», y a partir de 30 € (FREE_FROM) solo «El envío es gratis». Todo se calcula a partir de items. La comprobación añade queso hasta 34,00 €, luego vuelve por debajo de 30 € y quita el pan.
Consola
Solución
App.jsximport { useState } from 'react';
import { money } from './money.js';
const START = [
{ id: 7, name: 'Queso blanco', price: 7.9, qty: 2 },
{ id: 1, name: 'Pan blanco', price: 1.2, qty: 2 },
];
const FREE_FROM = 30;
export default function App() {
const [items, setItems] = useState(START);
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const free = total >= FREE_FROM;
function more(item) {
setItems(items.map((i) => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i)));
}
function less(item) {
if (item.qty === 1) setItems(items.filter((i) => i.id !== item.id));
else setItems(items.map((i) => (i.id === item.id ? { ...i, qty: i.qty - 1 } : i)));
}
return (
<main>
<h1>Tu carrito</h1>
<ul>
{items.map((item) => (
<li key={item.id}>
<span>{item.name} × {item.qty}</span>
<button type="button" aria-label={`Menos ${item.name}`} onClick={() => less(item)}>−</button>
<button type="button" aria-label={`Más ${item.name}`} onClick={() => more(item)}>+</button>
</li>
))}
</ul>
<p>Artículos: {count}</p>
<p>Total: {money(total)}</p>
{free ? <p>El envío es gratis</p> : <p>Para el envío gratis faltan {money(FREE_FROM - total)}</p>}
</main>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button { font: inherit; }
button[aria-pressed="true"] { background: #2f6f4f; color: white; }
La cantidad, el total y free se calculan a partir de items; el mensaje del envío es renderizado condicional: free o el importe restante FREE_FROM - total. La comprobación supera los 30 € y vuelve por debajo. Por eso se detecta un «free» guardado aparte: pasa a true con «+», pero nadie lo devuelve con «−». Por la misma razón la comprobación también detecta useMemo(…, []): el array vacío dice «nunca calcular de nuevo».
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Listas y claves · Condiciones: vacío, error, cargando · Estado · Actualizar objetos y arrays
- Continúa en: Estado compartido: hacia arriba · Efectos · Estado compartido · Enrutamiento
- Ejercicios sobre el tema: Estado