wandres.dev
INMUTABILIDAD · structural sharing

El coste real de copiar y la mutación controlada

De dónde viene el mito de que copiar es caro, la diferencia entre copia superficial y profunda, y cuándo la mutación controlada es correcta: rendimiento en estructuras grandes y el principio de la mutación no observable.

⏱ 16 min

La inmutabilidad es un default excelente, no una religión. Un ingeniero maduro sabe medir el coste real de copiar —que casi nunca es el que teme— y sabe también reconocer los pocos casos en los que la mutación controlada es la respuesta correcta, no una herejía. La regla que lo gobierna todo es una sola: la mutación es segura mientras nadie más pueda observarla. Este nivel afila el criterio para no pagar copias que no hacen falta ni sacrificar garantías que sí importan.

🎯 Al terminar esta lección sabrás
  • Distinguir copia superficial de copia profunda y sus costes reales.
  • Enunciar y aplicar el principio de la mutación no observable.
  • Usar mutación local controlada sin romper la inmutabilidad externa.
  • Decidir con datos cuándo una estructura grande pide otra estrategia.

Superficial contra profundo: el origen del mito

Casi todo el miedo a la copia viene de confundir dos operaciones muy distintas. La copia superficial —{ ...obj }, Object.assign, array.slice— crea un contenedor nuevo con las mismas referencias dentro: su coste es O(n) en el número de claves del nodo, y n es pequeño si el estado está bien estructurado. La copia profunda —structuredClone o un clon recursivo— duplica cada valor en cada nivel: su coste es O(total de nodos), y ahí sí duele.

  • Copia superficial con { ...obj }: O(claves del nodo). Copia punteros. Barata.
  • Copia profunda con structuredClone: O(nodos totales). Copia valores. Cara.
  • Igualdad por referencia con Object.is: O(1).
  • Igualdad profunda estructural: O(nodos totales).

La actualización inmutable correcta usa copia superficial más structural sharing: nunca copia en profundidad. El error clásico de rendimiento no es “usar inmutabilidad”, es clonar en profundidad sin necesidad —por ejemplo, un structuredClone del estado entero antes de cambiar un solo campo—. Ese patrón sí es O(n) y sí es evitable.

// MAL: copia profunda innecesaria, O(nodos totales)
const next = structuredClone(state);
next.user.name = "Grace";

// BIEN: copia superficial del camino, O(profundidad), con sharing
const next2 = { ...state, user: { ...state.user, name: "Grace" } };

La distancia no es teórica: es de varios órdenes de magnitud, y esa distancia es justo la que la gente atribuye por error a “la inmutabilidad”.

const estado = { filas: Array.from({ length: 100_000 }, (_, i) => ({ i, v: 0 })) };

let t = performance.now();
const prof = structuredClone(estado);            // clona las 100 000 filas
console.log("profunda:", performance.now() - t);

t = performance.now();
const sup = { ...estado, filas: estado.filas };  // reutiliza el mismo array
console.log("superficial:", performance.now() - t);

Además de cara, structuredClone es limitada: no clona funciones, ni nodos del DOM, ni conserva el prototipo de tus clases. Usarla como “copia universal del estado” es un antipatrón por partida doble: lenta y frágil.

El principio de la mutación no observable

Aquí está la idea que reconcilia rendimiento y pureza: mutar es perfectamente seguro mientras el valor mutado no sea observable por nadie más. Si construyes un valor en local, lo mutas cuanto quieras mientras lo estás construyendo, y solo lo expones cuando ya está terminado, nadie ve jamás un estado intermedio. Desde fuera, la operación es indistinguible de una pura. La transparencia referencial se preserva.

// CORRECTO: mutacion local no observable. Construyo y luego "sello".
function rango(n: number): readonly number[] {
  const out: number[] = [];                  // nadie mas tiene esta referencia
  for (let i = 0; i < n; i++) out.push(i);    // muto mientras construyo
  return out;                                 // desde aqui se trata como inmutable
}
// El patron de Immer, a mano: copio UNA vez, muto el borrador local, sello.
function aplicarLote(base: Estado, cambios: Cambio[]): Estado {
  const draft = { ...base, items: [...base.items] };   // copia superficial unica
  for (const c of cambios) draft.items[c.i] = c.valor;  // muto el borrador local
  return Object.freeze(draft);               // sello antes de exponerlo
}

La diferencia entre esto y la mutación peligrosa es quién tiene la referencia. En aplicarLote, draft es local: ninguna otra parte del programa lo ha visto todavía, así que mutarlo en el bucle no viola ninguna garantía. En cuanto lo devuelves —lo haces observable— dejas de tocarlo. Immer aplica exactamente este principio a escala, y por eso es rápido: agrupa todos tus cambios en una sola copia por nodo tocado.

Este principio explica también una regla que confunde a quien empieza en React: llamar al setter de useState con el mismo array que mutaste in situ no re-renderiza. React compara por referencia; si mutaste, la referencia es la misma y React concluye —correctamente— que nada cambió. La solución no es “forzar el render”, es respetar el contrato y producir una referencia nueva.

// PELIGRO: 'DEFECTO' es un valor por defecto compartido entre llamadas.
const DEFECTO = { reintentos: 3, cabeceras: {} as Record<string, string> };
function pedir(url: string, opts = DEFECTO) {
  opts.cabeceras["X-Trace"] = crypto.randomUUID();  // muta el default GLOBAL
  return fetch(url, opts);
}
// Cada llamada corrompe DEFECTO: la referencia escapo desde el primer uso.
⚠️
La trampa: mutar algo que ya escapó

El bug clásico es mutar un valor que crees local pero que en realidad compartes: un objeto del estado que pasaste por referencia, un elemento de un array que también vive en otra versión, un valor por defecto reutilizado entre llamadas como el de arriba. En cuanto una referencia escapó a otra parte del programa, mutarla es un cambio invisible que corrompe comparaciones, memos e historial. Local significa que solo tú tienes el puntero: si alguien más pudo verlo, ya no lo es.

Estructuras grandes: medir, no adivinar

Hay un régimen donde la copia sí manda: estructuras muy grandes y muy calientes. Una tabla de cientos de miles de filas que se actualiza celda a celda muchas veces por segundo, un buffer de audio o vídeo, una matriz numérica, un grafo de simulación. Aquí, incluso la copia superficial del nodo raíz puede dominar el perfil, y clonar en cada frame no es opción.

Síntomas de que has entrado en ese régimen:

  • El perfilador señala la copia del estado, no el render, como punto caliente.
  • La estructura tiene decenas o cientos de miles de entradas en un mismo nivel.
  • Las actualizaciones ocurren a frecuencia de animación, muchas veces por segundo.

Las estrategias, por orden de preferencia:

  • Reestructurar el estado: normalizar por id y anidar por secciones para que cada actualización toque nodos pequeños. Resuelve la mayoría de los casos sin abandonar la inmutabilidad.
  • Usar estructuras persistentes: tries de factor 32 (Immutable.js, librerías tipadas modernas) que dan actualización efectivamente constante sobre colecciones enormes.
  • Aislar la mutación: encapsular una estructura mutable —un TypedArray, un Map— tras una frontera que expone solo lecturas inmutables, mutando en un worker o fuera del ciclo de render.
  • Mutación local controlada: cuando el valor de verdad no escapa, aplicar el principio anterior y sellar al final.
// Frontera: mutacion encapsulada, lectura inmutable hacia afuera.
class Malla {
  #datos: Float64Array;                 // mutable, privado, nadie de fuera lo ve
  constructor(n: number) { this.#datos = new Float64Array(n); }
  set(i: number, v: number): void { this.#datos[i] = v; }        // muta internamente
  get snapshot(): readonly number[] { return Array.from(this.#datos); }  // copia al leer
}

Fíjate en el patrón de Malla: por dentro es un Float64Array mutado en O(1) por escritura, pero hacia afuera solo expone snapshot, una copia inmutable puntual. La mutación vive encapsulada tras la clase; ningún consumidor puede observar un estado a medias. Es el mismo principio de la mutación no observable elevado a frontera arquitectónica: mutable dentro, inmutable en el contrato público.

💡
Perfila antes de optimizar la copia

Antes de introducir mutación o estructuras exóticas, mide. Usa performance.now, el panel de rendimiento del navegador o el Profiler de React para localizar el nodo caliente. Nueve de cada diez veces el cuello no es la copia, sino un render que no debía ocurrir o un selector mal memoizado —problemas que la inmutabilidad ayuda a resolver, no a causar—.

El criterio profesional no es “inmutable siempre” ni “muta si va lento”: es medir. Perfila con datos reales, localiza el nodo caliente, y aplica la estrategia mínima que resuelva ese cuello sin contaminar el resto del estado, que debe seguir siendo inmutable por defecto. En 2026, con el React Compiler memoizando por ti, el contrato de inmutabilidad importa más que nunca: el compilador confía en que no mutas, y su optimización se apoya en esa promesa.

flowchart TD
Q0[voy a cambiar el estado] --> Q1[otro codigo observa el valor intermedio]
Q1 -->|si| INM[actualizacion inmutable con sharing]
Q1 -->|no| Q2[la copia domina el perfil medido]
Q2 -->|no| INM
Q2 -->|si| MUT[mutacion local controlada y sello final]
La inmutabilidad es el default; la mutación, la excepción medida

La madurez con el estado no consiste en jurar lealtad a la inmutabilidad ni en presumir de optimizar con mutaciones: consiste en entender por qué la inmutabilidad es el default correcto y en reconocer con precisión quirúrgica los pocos lugares donde no lo es. El default es correcto porque el coste que la gente teme —la copia profunda— casi nunca es el coste que se paga: la actualización inmutable bien hecha copia caminos, no mundos, y comparte todo lo demás. La excepción es legítima solo cuando se cumplen dos condiciones a la vez: que la mutación no sea observable desde fuera, de modo que la pureza externa se preserve, y que un perfilado real —no una corazonada— demuestre que la copia es el cuello de botella. Fuera de esa intersección, mutar es cambiar comodidad microscópica por bugs macroscópicos en comparaciones, memoización, historial y concurrencia. Y dentro de ella, la técnica no es “mutar a lo loco”, sino encapsular la mutación tras una frontera que sigue exponiendo valores inmutables. El ingeniero que interioriza esto deja de discutir dogmas y empieza a tomar decisiones: inmutable por defecto, mutable por excepción, y siempre con el perfilador delante.

⚔️ Afina tu criterio de coste
  1. Mide structuredClone frente a { ...obj } sobre el mismo estado grande y cuantifica la diferencia en órdenes de magnitud.
  2. Toma una función que clona en profundidad “por si acaso” y reescríbela con copia superficial más sharing; verifica que el comportamiento externo es idéntico.
  3. Implementa la clase Malla y demuestra que, aunque mute por dentro, un consumidor externo solo obtiene snapshots inmutables.
  4. Elige una estructura caliente de una app real, perfílala y decide con datos si pide normalización, estructura persistente o frontera mutable. Justifica la elección.