Inicio / Artículos

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.

Editor: REACTAPP.TOP6 min de lectura1280 palabras

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 de splice().
  • Sustituir: cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)).
  • Actualización de un objeto: setUser({ ...user, name: 'Alice' }) en lugar de asignar a una propiedad.
  • Ordenamiento o inversión: primero haz una copia con [...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.

    1. 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.
    2. 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.
    3. 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.is de 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() o state.property = value en el estado.
    • Cree un nuevo array u objeto mediante la expansión, map o filter, y copie cada nivel anidado que haya modificado.
  • Utilice el actualizador funcional cuando el estado nuevo depende del estado anterior, especialmente en casos de actualizaciones rápidas o asíncronas.
  • 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