Actualizar objetos y arrays
En resumen: Un objeto o un array en el estado de React se actualiza con una copia nueva (spread, map, filter), porque React compara referencias y no ve un cambio hecho en el viejo.
Problema
«Quitar» no quita nada
El carrito tiene tres líneas, cada una con un botón «Quitar». Pulsa «Quitar» en el pan.
Consola
La consola dice líneas: 2. La pantalla muestra las tres líneas y el total viejo. Los datos cambiaron, la pantalla no los sigue, y no hay ningún error.
remove saca la línea con splice y llama a setCart(cart). Se pasó el valor nuevo. ¿Por qué React no dibuja?La idea
Cómo ve la biblioteca un cambio
Con un número está claro: 1 y 2 son distintos. Un array y un objeto, en cambio, son referencias: la variable apunta a un objeto en la memoria. Hay dos preguntas. ¿La variable apunta a otro objeto? ¿Y cambió el contenido del mismo objeto? Cada biblioteca pregunta a su manera, y aquí la diferencia se nota en el código.
React hace solo la primera pregunta. Con setCart(next) compara el estado viejo y el nuevo con Object.is. Para un array eso significa «¿es el mismo objeto?», no «¿tiene el mismo contenido?».
splice cambia el contenido del mismo array. setCart(cart) pasa la misma referencia, Object.is dice «el mismo», y React se salta el render. Por eso en React el array y los objetos del estado no se cambian en el sitio. Haces un array nuevo, y React ve una referencia nueva. Esto se llama una actualización inmutable (immutable update).
Míralo
Mira: el mismo array o uno nuevo
const [cart, setCart] = useState([bread]);
function addMilk() { cart.push(milk); setCart(cart);}
function addCheese() { setCart([...cart, cheese]);}Paso a paso, en palabras
- cart apunta a un array con una línea. React guarda esta referencia como estado del componente, y la pantalla muestra el pan.
- cart.push(milk) añade una línea al mismo array. El contenido cambió, pero es el mismo objeto.
- setCart(cart) pasa la misma referencia. React compara la vieja y la nueva con Object.is: el mismo objeto.
- El mismo significa «nada nuevo»: React se salta el render. La leche está en el array, pero no en la pantalla.
- [...cart, cheese] hace un array nuevo: una copia de las líneas más el queso.
- setCart con el array nuevo: Object.is dice «distintos», y React vuelve a dibujar.
- Ahora la pantalla muestra las tres líneas. También la leche, que hasta ahora estaba escondida en el array viejo.
- La regla en React: no cambies el array del estado, dale uno nuevo.
push, pero aparece en la pantalla solo junto con el queso. El código con una mutación no falla sin más. Esconde un cambio que sale después, en una acción totalmente distinta.La idea
Un array nuevo, un objeto nuevo
El carrito tiene tres acciones: añadir una línea, cambiar la cantidad de una línea, quitar una línea.
// añadir: un array nuevo con las líneas viejas y la nuevasetCart([...cart, { ...product, qty: 1 }]);
// cambiar una línea: un array nuevo, la línea cambiada es un objeto nuevosetCart(cart.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line)));
// quitar: un array nuevo sin la líneasetCart(cart.filter((line) => line.id !== id));[...cart, x]es un array nuevo: los elementos viejos yxal final.{ ...line, qty: 5 }es un objeto nuevo: los campos deline, conqtycambiado.mapyfiltersiempre devuelven un array nuevo. Por eso son las herramientas principales aquí.
Pruébalo
Arregla «Quitar»
Este es el carrito del principio. Arregla remove para que la línea desaparezca y el total se calcule de nuevo. La comprobación quita el pan y después el queso.
Consola
Solución
App.jsximport { useState } from 'react';
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export default function App() {
const [cart, setCart] = useState([
{ id: 1, name: 'Pan blanco', price: 1.2, qty: 2 },
{ id: 5, name: 'Leche fresca', price: 1.6, qty: 1 },
{ id: 7, name: 'Queso blanco', price: 7.9, qty: 1 },
]);
function remove(id) {
setCart(cart.filter((line) => line.id !== id));
}
const total = cart.reduce((sum, line) => sum + line.price * line.qty, 0);
return (
<main>
<h2>Tu carrito</h2>
<ul>
{cart.map((line) => (
<li key={line.id}>
{line.name}: {line.qty} × {money(line.price)}
<button type="button" onClick={() => remove(line.id)} aria-label={`Quitar ${line.name}`}>
Quitar
</button>
</li>
))}
</ul>
<p>Total: {money(total)}</p>
</main>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
h2 { font-size: 18px; margin: 16px 0 4px; }
ul { padding-left: 20px; }
li { margin: 6px 0; }
button { font: inherit; padding: 2px 10px; margin-left: 6px; }
setCart(cart.filter((line) => line.id !== id)): filter devuelve un array nuevo sin la línea, y React lo ve. const next = cart no es una copia: next y cart apuntan al mismo array, y splice sobre next es splice sobre el estado.Pruébalo
Encuentra el error
«Quitar» no quita. ¿Qué línea tiene la culpa?
Elige la línea con el error.
Línea 2: const next = cart no copia el array, le da al mismo array un segundo nombre. splice cambia el estado en el sitio y setCart(next) pasa la misma referencia. Debería ser setCart(cart.filter((line) => line.id !== id)).
La idea
Una línea del array
La clienta pulsa «+» en el pan. Tiene que cambiar el qty de un objeto, y el objeto está en un array.
cart.find(...).qty++ cambia el objeto en el sitio: es el mismo objeto que React guarda como estado viejo. El array es el mismo, así que React no vuelve a dibujar. El número no desaparece: llega a la pantalla en el siguiente render por otro motivo, y la cantidad «salta».
Cuidado también con las copias. [...cart] es un array nuevo, pero las líneas que contiene son los mismos objetos. Esto es una copia superficial (shallow copy): solo se copia el primer nivel. copy[0].qty++ cambia la línea también en el estado viejo. La línea cambiada tiene que ser un objeto nuevo: { ...line, qty: line.qty + 1 }. Las líneas que no cambian siguen siendo los mismos objetos, y está bien.
Más precisamente, para curiosos
Object.is. Como ===, con dos diferencias: Object.is(NaN, NaN) es true, y Object.is(0, -0) es false. Con arrays y objetos, los dos comparan referencias.
Objetos anidados. Cada nivel del camino hasta el cambio es nuevo: { ...order, address: { ...order.address, city: 'Varna' } }. Los niveles de al lado siguen siendo los mismos.
structuredClone(cart) hace una copia profunda: el array y cada objeto que contiene. Funciona, pero copia todo en cada cambio y no puede copiar funciones. Para una línea, map y ... salen más baratos.
Immer. La biblioteca Immer te deja escribir como si mutaras (draft[0].qty++) y hace los objetos nuevos por ti. No está en el sandbox.
En Vue también funciona. El array nuevo es correcto también en Vue: cart.value = cart.value.filter(...). Al revés no vale: la mutación, normal en Vue, no se ve en React.
Pruébalo
«+» y «−»
Escribe more y less. «+» sube la cantidad de la línea en 1, y «−» la baja en 1. Cuando la cantidad es 1, «−» quita la línea. El total sigue a la cantidad.
Consola
Solución
App.jsximport { useState } from 'react';
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export default function App() {
const [cart, setCart] = useState([
{ id: 1, name: 'Pan blanco', price: 1.2, qty: 2 },
{ id: 5, name: 'Leche fresca', price: 1.6, qty: 1 },
{ id: 7, name: 'Queso blanco', price: 7.9, qty: 1 },
]);
function more(id) {
setCart(cart.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line)));
}
function less(id) {
const line = cart.find((l) => l.id === id);
if (line.qty === 1) {
remove(id);
} else {
setCart(cart.map((l) => (l.id === id ? { ...l, qty: l.qty - 1 } : l)));
}
}
function remove(id) {
setCart(cart.filter((line) => line.id !== id));
}
const total = cart.reduce((sum, line) => sum + line.price * line.qty, 0);
return (
<main>
<h2>Tu carrito</h2>
<ul>
{cart.map((line) => (
<li key={line.id}>
{line.name}: {line.qty} × {money(line.price)}
<button type="button" onClick={() => less(line.id)} aria-label={`Menos ${line.name}`}>−</button>
<button type="button" onClick={() => more(line.id)} aria-label={`Más ${line.name}`}>+</button>
<button type="button" onClick={() => remove(line.id)} aria-label={`Quitar ${line.name}`}>
Quitar
</button>
</li>
))}
</ul>
<p>Total: {money(total)}</p>
</main>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
h2 { font-size: 18px; margin: 16px 0 4px; }
ul { padding-left: 20px; }
li { margin: 6px 0; }
button { font: inherit; padding: 2px 10px; margin-left: 6px; }
more es setCart(cart.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line))). less encuentra la línea y, cuando qty === 1, llama a remove; si no, hace el mismo map con - 1. Si escribes line.qty++ y setCart(cart), React no dibuja: el array y la línea son los mismos. Sin la comprobación del 1, la línea llega a «0 ×» y se queda en el carrito.
Ejercicio
Catálogo y carrito
El catálogo tiene cuatro productos con un botón «Añadir» (el nombre para el lector de pantalla: «Añadir Pan blanco»). Escribe add y remove. El primer clic en «Añadir» hace una línea con cantidad 1, y los siguientes suben la cantidad de la misma línea en lugar de hacer una nueva. «Quitar» quita la línea. Cuando el carrito está vacío, dice «El carrito está vacío».
Consola
Solución
App.jsximport { useState } from 'react';
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
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 },
{ id: 10, name: 'Manzanas', price: 2.1 },
];
export default function App() {
const [cart, setCart] = useState([]);
function add(product) {
const found = cart.find((line) => line.id === product.id);
if (found) {
setCart(cart.map((line) => (line.id === product.id ? { ...line, qty: line.qty + 1 } : line)));
} else {
setCart([...cart, { ...product, qty: 1 }]);
}
}
function remove(id) {
setCart(cart.filter((line) => line.id !== id));
}
const total = cart.reduce((sum, line) => sum + line.price * line.qty, 0);
return (
<main>
<h1>La tienda</h1>
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name}, {money(p.price)}
<button type="button" onClick={() => add(p)} aria-label={`Añadir ${p.name}`}>
Añadir
</button>
</li>
))}
</ul>
<h2>Tu carrito</h2>
{cart.length === 0 && <p>El carrito está vacío</p>}
<ul aria-label="Tu carrito">
{cart.map((line) => (
<li key={line.id}>
{line.name}: {line.qty} × {money(line.price)}
<button type="button" onClick={() => remove(line.id)} aria-label={`Quitar ${line.name}`}>
Quitar
</button>
</li>
))}
</ul>
<p>Total: {money(total)}</p>
</main>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
h2 { font-size: 18px; margin: 16px 0 4px; }
ul { padding-left: 20px; }
li { margin: 6px 0; }
button { font: inherit; padding: 2px 10px; margin-left: 6px; }
add busca la línea con find. Si está: map con un objeto nuevo para ella. Si no: setCart([...cart, { ...product, qty: 1 }]). { ...product, qty: 1 } es un objeto nuevo, y el producto del catálogo no se toca. remove es filter. Con cart.push y setCart(cart) el carrito se queda vacío, y con [...cart, …] cada clic añade una línea nueva para el mismo producto y dos líneas comparten un key.Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: El primer componente · Listas y claves · Condiciones: vacío, error, cargando · Estado
- Continúa en: Estado derivado · Estado compartido: hacia arriba · Estado compartido · El escaparate de la tienda
- Ejercicios sobre el tema: Estado