Por qué push() deja la interfaz de usuario de React obsoleta y cómo las nuevas referencias lo solucionan
Entienda por qué mutar arrays en useState nunca provoca un nuevo renderizado, cómo funciona la verificación de referencias en React y cuándo utilizar copias con spread y actualizaciones funcionales.
Fuentes: React Survival Guide, Part 2: Don’t Mutate! Why `.push()` is Killing Your UI
Pocos errores de React son tan desconcertantes como una interfaz de usuario que se niega a actualizarse mientras la consola insiste en que los datos son correctos. No hay error ni advertencia, solo una pantalla atascada en valores antiguos. En la abrumadora mayoría de los casos, la causa es la mutación del estado: modificar un array u objeto en su lugar en vez de reemplazarlo. Este artículo explica exactamente por qué React ignora los cambios en el mismo lugar, muestra la solución de una sola línea y aborda la forma de actualización funcional que se necesita cuando las actualizaciones se acumulan rápidamente.
El error: los datos cambian, pero la pantalla no
Imagínese un pequeño carrito de compras, una lista de tareas o una tabla de usuarios. La última acción consiste en agregar un elemento a un array almacenado en useState, y el primer intento parece ser un código JavaScript normal.
const [cart, setCart] = useState(['Coffee']);
const addItem = (newItem) => {
// It's just JavaScript, right?
cart.push(newItem);
console.log("Added! Cart is now:", cart);
};
Después de hacer clic en “Añadir al carrito”, el registro muestra ['Coffee', 'Tea'], por lo que los datos parecen correctos. Sin embargo, el carrito mostrado solo enumera “Coffee”. La interfaz ahora muestra a los usuarios información que ya no es verdadera, lo cual es peor que un cierre inesperado, ya que nadie se da cuenta hasta que lo hace un cliente. En situaciones como esta, los desarrolladores suelen perder una hora revisando bucles y lógica de renderizado que en realidad nunca fueron el problema.
Fíjese que este manejador nunca llama a setCart. Nada le indica a React que ocurrió algo, por lo que no tiene motivo para renderizar. La versión más sutil del error agrega setCart(cart) después de push(), y la pantalla sigue sin cambiar. La sección siguiente explica por qué.
React compara referencias, no contenidos
La idea clave que hay que internalizar es sencilla: React hace seguimiento de la identidad, no del contenido.
Métodos como .push(), .pop(), .splice() y .unshift(), o una asignación como user.name = 'Alice', son mutaciones. Estos métodos indican a JavaScript que cambie el valor almacenado en una ubicación existente en la memoria. JavaScript lo hace de inmediato, y por eso console.log(cart) muestra el nuevo elemento. Sin embargo, React no está monitoreando esa memoria; su modelo de actualización asume que el estado es inmutable.
Cuando se llama a un setter de estado, React compara el nuevo valor con el actual utilizando Object.is. En el caso de arrays y objetos, se trata de una comparación por referencia: solo verifica si se trata del mismo objeto que antes, no si su contenido difiere. Después de un push(), el array que se devuelve es el mismo objeto que React ya tiene almacenado. La comprobación indica “sin cambios”, React omite la vuelta a renderizar y la interfaz sigue mostrando la última versión guardada.
Esta comprobación sencilla es intencionada. Comparar referencias tarda tiempo constante, independientemente del tamaño del estado, mientras que una comparación profunda de cada valor anidado en cada actualización sería costosa. El compromiso es que debes indicar un cambio proporcionando una nueva referencia.
La solución: copiar, modificar la copia y establecerla
La inmutabilidad parece más exigente de lo que es en realidad. Para actualizar un array u objeto en el estado, hay que dejar intacto el original, crear uno nuevo que incluya el cambio y pasarlo al setter. En JavaScript moderno, el operador de propagación (...) es la forma más rápida de hacerlo.
La versión corregida de addItem crea un nuevo array que contiene los elementos antiguos seguidos del nuevo.
const addItem = (newItem) => {
// The right way (creating a new identity):
setCart([...cart, newItem]); // Create a new array, add newItem at the end.
};
Ahora el setter recibe una referencia diferente, la comprobación con Object.is falla y React vuelve a renderizar la interfaz con el elemento añadido. El mismo principio se aplica a otras operaciones:
- Eliminar:
cart.filter(item => item !== target)en lugar desplice(). - Sustituir:
cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)).
setUser({ ...user, name: 'Alice' }) en lugar de asignar a una propiedad.[...cart].sort(), o utiliza las funciones no mutantes toSorted() y toReversed() donde los navegadores de destino las soporten.Tenga en cuenta que la expansión crea copias solo a un nivel. Si el estado contiene objetos anidados, cada nivel que modifique necesitará su propia copia. Para conocer más sobre la mutación de referencias más allá del estado de React, incluyendo estructuras anidadas, consulte evitar errores silenciosos en el estado debido a la mutación de referencias en JavaScript.
Qué te aportan las actualizaciones inmutables
Esto no es solo una regla impuesta por React; también genera código que es más fácil de comprender.
- Puntos de actualización claros. Cada cambio de estado pasa por un setter, por lo que es evidente qué funciones modifican el estado y dónde.
- Depuración mejorada. Dado que cada actualización genera un valor distinto, React DevTools y otras herramientas pueden mostrar el estado antes y después de cada cambio.
- Sin efectos secundarios ocultos. Como los datos nunca se modifican en el mismo lugar, un error en un componente no puede dañar silenciosamente un array u objeto que otra parte de la aplicación sigue leyendo.
Actualizaciones funcionales para cambios rápidos o superpuestos
Surge un segundo problema relacionado con actualizaciones de alta frecuencia, como clics repetidos rápidamente o varias respuestas asíncronas que llegan muy cerca una de la otra, posiblemente fuera de orden. Cada manejador captura el valor del estado del renderizado en el que fue creado. Si dos actualizaciones se ejecutan antes de un nuevo renderizado, ambas pueden partir del mismo estado obsoleto, y un cambio sobrescribe al otro. Este es el problema de cierre obsoleto, y interactúa directamente con la forma en que se construye el nuevo estado.
La solución es pasar una función al setter. React la llama con el estado pendiente más reciente, de modo que cada actualización se basa en el resultado de la anterior y no en un valor capturado previamente. El ejemplo a continuación agrega un lote de elementos generados. Lee prevCart.length del argumento para numerarlos y luego devuelve un array completamente nuevo que combina los elementos anteriores con los nuevos.
// A very fast counter:
const addManyItems = (count) => {
// Good: Uses spread to be immutable
// Better: Uses functional update for consistency
setCart(prevCart => {
// We are generating new array content:
const newItems = Array.from({ length: count }, (_, i) => `Item ${prevCart.length + i + 1}`);
// Spread operator for the final, clean, new identity:
return [...prevCart, ...newItems];
});
};
Aquí colaboran dos factores. La expansión sigue generando una nueva referencia, lo que hace que React vuelva a renderizar. La función actualizadora garantiza que el array base sea el más reciente, lo cual evita que se pierdan las actualizaciones. Es buena práctica utilizar la forma funcional siempre que el estado siguiente se calcule a partir del estado anterior.
Conclusión
- Una mutación cambia los datos pero no la referencia, por lo que la comprobación de
Object.isde React concluye que no ha habido cambios y omite el renderizado. - Cuando la interfaz de usuario se queda atrás con respecto a los datos, primero busque llamadas como
.push(),.unshift(),.splice()ostate.property = valueen el estado. - Cree un nuevo array u objeto mediante la expansión,
mapofilter, y copie cada nivel anidado que haya modificado.
La solución suele requerir solo una línea de código, y marca la diferencia entre una interfaz en la que los usuarios pueden confiar y otra que muestra silenciosamente información obsoleta.
Lecturas relacionadas
- Prop Drilling No Es Una Razón para Instalar Redux o Zustand — Prueba cuatro razones comunes para agregar una biblioteca de estado en código React funcional: Contexto para el acceso a propiedades, useState, useSyncExternalStore y el costo de volver a renderizar del Contexto.
- Evitando errores de estado silencioso por mutación de referencia en JavaScript — Aprenda por qué mutar objetos y arrays por referencia interrumpe las actualizaciones de React, por qué el operador spread solo realiza copias superficiales, y cómo clonar profundamente el estado de forma segura.
- Prop Drilling no es una razón para instalar Redux o Zustand — Pruebe cuatro razones comunes para agregar una biblioteca estatal al código React en funcionamiento: el contexto para el acceso profundo a propiedades, useState, useSyncExternalStore y el costo de volver a renderizar del contexto.
- Reconsiderando el estado de React: dónde realmente deberían residir sus datos — Este artículo explica cómo reducir los errores en React al colocar el estado en la URL, el DOM o valores derivados, en lugar de usar excesivamente useState.
- Dónde mantiene su valor useState: Elementos de React frente a Fibers — Por qué un componente funcional de React olvida todo entre llamadas, por qué los elementos no pueden almacenar estado y cómo el campo memoizedState de Fiber mantiene vivos los valores de useState.
- Mida primero: por qué la optimización temprana hace que las aplicaciones Next.js sean más difíciles de ejecutar — Vea cómo la memorización prematura, los límites amplios de los clientes y las cachés en capas añaden complejidad a las aplicaciones Next.js, y cómo un flujo de trabajo centrado en la medición mantiene su velocidad.