Inicio / Artículos / Evitando errores de estado silencioso por mutación de referencia en JavaScript

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.

1565 palabras

Un usuario abre el modal de configuración de la cuenta en su producto SaaS, edita el nombre del espacio de trabajo de “Acme Marketing” a “Acme Global”, luego cambia de opinión y hace clic en Cancelar.

El modal desaparece. Pero el nombre del espacio de trabajo que se muestra en la barra de navegación superior ahora también dice “Acme Global”.

El usuario vuelve a cargar la página, perplejo. El nombre vuelve a ser “Acme Marketing”.

Cuando se investiga, se descubre que el estado del formulario estaba almacenado en el estado local del componente, y hacer clic en Cancelar nunca envió ninguna solicitud de red. Entonces, ¿cómo es que una edición que nunca se guardó terminó afectando al encabezado global?

El culpable son dos líneas de código aparentemente simples:

// In the modal component
const formState = currentUser.workspace;
formState.name = newName; // Direct object reference mutation!

Este es un modo de fallo clásico y fácil de pasar por alto en las aplicaciones JavaScript: mutación accidental a través de referencias compartidas.

Dado que los objetos y arrays en JavaScript se manejan por referencia en lugar de por valor, modificar un objeto dentro de un componente puede cambiar silenciosamente esos mismos datos subyacentes en todas las demás partes de la aplicación donde se utilicen, evitando así el sistema de gestión de estado, alterando la forma en que React decide qué rerenderizar y dejándote frente a un error sin causa aparente.

1. Igualdad por referencia: cómo ve JavaScript tus datos

Para comprender por qué las mutaciones causan tantos problemas, es útil saber cómo el motor de JavaScript almacena realmente tus valores en la memoria.

Los valores primitivos — números, cadenas de texto, booleanos, null, undefined — se copian por valor:

let a = 10;
let b = a;
b = 20;console.log(a); // 10 (unchanged)

Los objetos, arrays y funciones funcionan de manera diferente: se almacenan por referencia. Una variable que contiene un objeto no guarda directamente los datos del objeto, sino que alberga un puntero a la ubicación en memoria donde realmente se encuentran esos datos:

const userA = {
  name: "Sarah",
  role: "Admin"
};
const userB = userA; // userB points to the EXACT same memory address!userB.name = "Alex";console.log(userA.name); // "Alex" — userA was mutated!

Los frameworks de interfaz de usuario modernos como React dependen en gran medida de verificaciones de igualdad superficial (usando Object.is o ===) para decidir si un componente realmente necesita volver a renderizarse.

Por lo tanto, si mutas un objeto existente directamente y luego se lo pasas a setState:

// BAD: Mutating existing state directly
const [user, setUser] = useState({
  name: "Sarah",
  age: 30
});
function updateAge() {
  user.age = 31; // Direct mutation
  setUser(user); // Passes the SAME memory reference!
}

React compara la referencia del estado anterior con la nueva. Dado que son literalmente el mismo objeto en memoria, React decide que no ha habido cambios y omite por completo la re-renderización.

Los datos subyacentes se han actualizado, pero la pantalla permanece congelada en su estado anterior.

2. La ilusión del operador de propagación

Para evitar la mutación directa, muchos desarrolladores recurren al operador de propagación (...) en los objetos. Es una herramienta útil, pero existe el error común de pensar que genera una copia profunda completa.

No es así.

El operador de propagación solo duplica el nivel superior de un objeto. Cualquier objeto o array anidado sigue compartiendo referencia con el original.

Tomemos un objeto de configuración típico que podríamos encontrar en una aplicación SaaS:

const defaultSettings = {
  theme: "dark",
  notifications: {
    email: true,
    sms: false
  }
};
// Shallow copy using spread
const userSettings = { ...defaultSettings };// Changing a nested property
userSettings.notifications.email = false;// Disaster: defaultSettings was also mutated!
console.log(defaultSettings.notifications.email); // false!

Dado que notifications es a su vez un objeto, tanto userSettings.notifications como defaultSettings.notifications siguen apuntando al mismo bloque de memoria.

Si defaultSettings resulta ser una constante compartida a nivel de módulo, editar las preferencias de un usuario puede dañar silenciosamente la configuración por defecto utilizada en todas las demás partes de la aplicación.

3. Las minas terrestres de los métodos de array

JavaScript incluye varios métodos de array integrados que modifican el array en el que se ejecutan en lugar de devolver uno nuevo.

Si pasas un array proveniente de props o estado compartido a uno de estos métodos, obtendrás efectos secundarios no deseados:

// METHODS THAT MUTATE IN PLACE (Dangerous with state)
array.sort();     // Mutates original array!
array.reverse();  // Mutates original array!
array.splice();   // Mutates original array!
array.push();     // Mutates original array!
array.pop();      // Mutates original array!

Imagina un componente de tabla que muestra una lista de transacciones:

// BAD: Direct prop mutation during render
function TransactionTable({
  transactions
}: {
  transactions: Transaction[];
}) {
  // transactions.sort() permanently reorders the array in parent state!
  const sorted = transactions.sort(
    (a, b) => b.amount - a.amount
  );
  return (
    <table>
      {sorted.map((tx) => (
        <tr key={tx.id}>
          <td>{tx.amount}</td>
        </tr>
      ))}
    </table>
  );
}

Cada vez que se renderiza TransactionTable, se reorganiza silenciosamente el array de transacciones que en realidad pertenece al componente padre.

La solución moderna: Métodos de array que no mutan

Las versiones recientes de ECMAScript introdujeron contrapartes no mutantes a estos métodos, cada una de las cuales devuelve un array nuevo en lugar de modificar el original:

Método mutante (evitar) frente a su reemplazo no mutante (preferir): arr.sort(fn) se convierte en arr.toSorted(fn), arr.reverse() se convierte en arr.toReversed(), arr.splice(start, count) se convierte en arr.toSpliced(start, count), y arr[index] = val se convierte en arr.with(index, val).

En lugar de modificar el array de transacciones en su lugar:

// GOOD: Leaves the original transactions array pristine
const sorted = transactions.toSorted(
  (a, b) => b.amount - a.amount
);

4. Copiado profundo moderno: structuredClone vs. trucos con JSON

Cuando su aplicación realmente necesita una copia profunda y completamente independiente del estado anidado, ha llegado el momento de dejar de usar la antigua solución temporal JSON.parse(JSON.stringify(obj)).

Ese truco basado en JSON tiene varios problemas graves:

  • Las funciones y los valores undefined se descartan silenciosamente.
  • Los objetos Date se convierten en cadenas ISO simples en lugar de seguir siendo instancias de Date.
  • Los objetos Map, Set, RegExp y ArrayBuffer se destruyen en el proceso.
  • Las referencias circulares causan que se genere un error inmediato.

El estándar: structuredClone()

Todos los navegadores actuales y entornos de ejecución de Node.js incluyen soporte nativo para structuredClone():

const originalProject = {
  id: "proj_123",
  metadata: {
    createdAt: new Date(),
    tags: new Set(["frontend", "ui"])
  },
  collaborators: [
    { name: "Sarah" }
  ]
};
// Creates a complete, true deep copy
const clonedProject = structuredClone(originalProject);clonedProject.metadata.tags.add("react");
clonedProject.collaborators[0].name = "Alex";// Original remains completely untouched
console.log(
  originalProject.metadata.tags.has("react")
); // falseconsole.log(
  originalProject.collaborators[0].name
); // "Sarah"console.log(
  originalProject.metadata.createdAt instanceof Date
); // true

Al llamar a structuredClone sobre originalProject se obtiene una copia verdaderamente separada: modificar el conjunto de etiquetas de la copia o actualizar el nombre de un colaborador anidado no tiene ningún efecto en el objeto original, ya que toda estructura anidada se duplicó en lugar de referenciarse a ella.

5. Cuando la inmutabilidad se convierte en un problema de rendimiento

La inmutabilidad mantiene la lógica de la interfaz de usuario predecible, pero recurrir a una clonación profunda en cada actualización puede afectar negativamente el rendimiento si no se tiene cuidado.

Imagínese una cuadrícula de datos con 50,000 filas o un gráfico basado en canvas que realiza cálculos a 60 fotogramas por segundo. Clonar completamente toda la estructura, ya sea mediante structuredClone o de otra manera, en cada interacción genera una gran cantidad de basura que el recolector debe limpiar, lo que hace que el navegador se vuelva lento al asignar y liberar repetidamente grandes cantidades de memoria.

El enfoque equilibrado

  1. Solo haga una copia superficial del nivel que está modificando: si la actualización se limita a user.name, basta con una copia superficial del nivel superior:
{ ...user, name: "New Name" }
  1. Utilice bibliotecas de compartición estructural cuando la anidación sea profunda: para árboles de estado con muchas capas, herramientas como Immer le permiten evitar copias completas en profundidad. Immer se basa en objetos Proxy de JavaScript para clonar solo las ramas que realmente fueron modificadas, de modo que cada rama no afectada sigue apuntando a su referencia original.
import { produce } from "immer";
// Clean, intuitive mutation syntax with zero reference pollution
const nextState = produce(currentState, (draft) => {
  draft.users[0].preferences.theme = "dark";
});

Esto le brinda una sintaxis de tipo mutación que se lee de forma natural, mientras que Immer se encarga de generar un nuevo objeto de estado actualizado correctamente en segundo plano, sin compartir referencias accidentalmente.

Resumen y reglas de inmutabilidad

Para evitar errores imprevistos y corrupciones silenciosas en su código JavaScript:

  1. No modifique directamente las propiedades o el estado: trate cualquier dato proveniente del exterior de su ámbito local como de solo lectura.
  • Tenga en cuenta que la propagación es superficial: { ...obj } y [ ...arr ] solo copian el nivel más alto; los objetos y arrays anidados siguen siendo referencias compartidas.
  • Favorice los métodos de array to...: utilice toSorted(), toReversed() y toSpliced() en lugar de sus equivalentes mutantes como sort().
  • Use structuredClone para copias profundas reales: deje de depender de JSON.parse(JSON.stringify()) al tratar con datos anidados complejos.
  • Aproveche el compartir estructural: para estados profundamente anidados, Immer le permite actualizar los datos de manera limpia sin tener que copiar todo.
  • Respetar la igualdad de referencias y mantener la disciplina en cuanto a la inmutabilidad elimina toda una categoría de errores en producción: aquellos que, de otro modo, causarían horas de depuración confusa.

    Lecturas relacionadas

  • Cómo dibuja el navegador y dónde encaja React — Aprenda cómo el Path de Renderizado Crítico, la reconciliación, Fiber y el programador trabajan juntos para convertir las actualizaciones de React en píxeles en la pantalla.
  • Rendimiento del frontend: desde el punto ciego en la revisión de código hasta la métrica del producto — Entienda por qué pasar una revisión de código no es suficiente, cuáles son los Core Web Vitals realmente importantes y cómo medir y solucionar problemas reales de rendimiento en React.