wandres.dev
INMUTABILIDAD · structural sharing

Por qué la inmutabilidad domina el estado

Comparación por referencia en O(1), undo/redo casi gratis y time-travel debugging: las tres superpotencias que solo son baratas si el estado es inmutable, y sobre las que descansa todo el frontend moderno.

⏱ 15 min

La inmutabilidad no es una moda ni un dogma de pureza funcional: es el sustrato que vuelve baratas tres superpotencias que, sobre estado mutable, serían prohibitivas. Comparar dos versiones del mundo en tiempo constante, guardar el historial completo para deshacer, y viajar en el tiempo entre estados para depurar. Todo el ecosistema moderno —de React a Redux Toolkit, de los selectores memoizados al React Compiler— descansa sobre una única promesa: si cambia, es otra referencia; si no cambia, es la misma. Este nivel explica por qué esa promesa lo cambia todo.

🎯 Al terminar esta lección sabrás
  • Entender la igualdad por referencia y por qué es O(1).
  • Ver cómo la memoización y el renderizado dependen de ella.
  • Implementar undo/redo apoyándote en versiones inmutables.
  • Comprender el time-travel debugging y qué lo hace posible.

La igualdad por referencia: el truco que lo cambia todo

Comparar si dos objetos son “iguales” en profundidad cuesta O(n): hay que recorrer cada clave, cada elemento, cada nivel de anidamiento. Comparar si son la misma referencia cuesta O(1): es comparar dos punteros. La inmutabilidad convierte la primera pregunta en la segunda. Si te comprometes a no mutar nunca, entonces “¿cambió este dato?” se responde con prev === next —o con Object.is, que React usa internamente— en tiempo constante.

// Estado mutable: el cambio es INDETECTABLE por referencia
const state = { user: { name: "Ada" }, todos: [] };
state.user.name = "Grace";     // misma referencia
state === state;               // true -> nadie sabe que algo cambio

// Estado inmutable: cada cambio produce una referencia nueva
const next = { ...state, user: { ...state.user, name: "Grace" } };
next === state;                // false -> el cambio es DETECTABLE en O(1)
next.todos === state.todos;    // true  -> lo que no cambio se COMPARTE

Fíjate en la última línea: next.todos sigue siendo el mismo array que state.todos. No copiamos lo que no tocamos. Esa es la semilla del structural sharing del próximo nivel, y la razón de que “crear una versión nueva” no signifique “copiarlo todo”.

ℹ️
Object.is, no ===

React compara con Object.is, casi idéntico a === salvo en dos casos: Object.is(NaN, NaN) es true y Object.is(0, -0) es false. Para estado inmutable la diferencia rara vez importa, pero explica por qué la documentación de React habla siempre de Object.is y no de igualdad estricta.

Sobre esta comparación barata se construye toda la maquinaria de rendimiento del frontend de 2026. React.memo, useMemo y las listas de dependencias de los hooks comparan por referencia. Los selectores de Redux Toolkit y de Zustand deciden si re-renderizar comparando su salida por referencia. Incluso el React Compiler, que memoiza de forma automática y ha reducido la necesidad de useMemo manual, sigue asumiendo el mismo contrato: si mutas en lugar de reemplazar, la comparación miente y la UI se queda obsoleta.

// La lista de dependencias compara `todos` por referencia (Object.is)
const visibles = useMemo(() => todos.filter((t) => !t.done), [todos]);
// Si mutas `todos` in situ, la referencia no cambia: useMemo devuelve
// el resultado viejo y la interfaz muestra datos rancios.

El mismo principio gobierna los selectores. Uno memoizado —createSelector de Reselect, integrado en Redux Toolkit— cachea su resultado y solo recalcula si alguna de sus entradas cambió de referencia. Es memoización a escala de la aplicación, y solo funciona si el estado es inmutable.

import { createSelector } from "@reduxjs/toolkit";

const seleccionarVisibles = createSelector(
  [(s: Estado) => s.todos, (s: Estado) => s.filtro],
  (todos, filtro) => todos.filter((t) => t.estado === filtro),
);
// Recalcula solo si `todos` o `filtro` estrenan referencia. Con estado
// mutable la referencia nunca cambia y el selector devuelve basura cacheada.

Undo/redo casi gratis

Si cada acción produce una versión nueva e inmutable, el historial es simplemente una pila de referencias a versiones anteriores. No guardas diffs ni copias profundas: guardas punteros. Deshacer es mover un puntero.

type Historia<S> = { pasado: S[]; presente: S; futuro: S[] };

function aplicar<S>(h: Historia<S>, siguiente: S): Historia<S> {
  return { pasado: [...h.pasado, h.presente], presente: siguiente, futuro: [] };
}

function deshacer<S>(h: Historia<S>): Historia<S> {
  const previo = h.pasado.at(-1);
  if (previo === undefined) return h;
  return {
    pasado: h.pasado.slice(0, -1),
    presente: previo,
    futuro: [h.presente, ...h.futuro],
  };
}

Rehacer es la operación simétrica: mueve el presente al pasado y toma del futuro. Ninguna de las dos copia el estado; solo reordena referencias.

function rehacer<S>(h: Historia<S>): Historia<S> {
  const [siguiente, ...resto] = h.futuro;
  if (siguiente === undefined) return h;
  return { pasado: [...h.pasado, h.presente], presente: siguiente, futuro: resto };
}

Encadenar estas operaciones es solo reasignar una referencia, nunca copiar el estado:

let h: Historia<Estado> = { pasado: [], presente: inicial, futuro: [] };
h = aplicar(h, siguiente);   // avanza al nuevo estado
h = deshacer(h);             // retrocede: presente vuelve a `inicial`
h = rehacer(h);              // vuelve a `siguiente`

Cada entrada de pasado pesa lo que pesa un puntero, porque comparte estructura con las demás versiones. Un historial de mil pasos sobre un estado grande no ocupa mil copias del estado: ocupa mil referencias más las partes que efectivamente cambiaron. Sobre estado mutable esto es imposible: para “recordar” el pasado tendrías que clonar en profundidad en cada paso, y aun así no podrías comparar versiones baratas.

💡
Historial acotado sin perder baratura

Guardar historial ilimitado rara vez tiene sentido. Acota el pasado a las últimas N versiones con un buffer circular: como cada entrada es una referencia, descartar las viejas solo suelta punteros y el recolector libera las partes que ya no comparte nadie. El coste de memoria del undo se vuelve predecible y sigue siendo proporcional a lo que de verdad cambió, no al tamaño total del estado.

Time-travel debugging

El time-travel debugging —popularizado por Redux DevTools y hoy presente en Zustand, Jotai y las herramientas de XState— consiste en saltar libremente entre estados pasados de la aplicación y ver la UI exactamente como estaba. Solo es posible porque cada estado es un valor inmutable e independiente: seleccionar una versión anterior no tiene efectos secundarios, no hay que “deshacer mutaciones”, basta con volver a renderizar a partir de ese valor.

flowchart LR
A0[estado v0] -->|incrementar| A1[estado v1]
A1 -->|agregar todo| A2[estado v2]
A2 -->|filtrar activos| A3[estado v3]
A3 -.saltar a v1.-> A1
A1 -.reproducir.-> A2

El depurador guarda la secuencia de acciones y el estado resultante de cada una. Como los estados son inmutables, puede reconstruir cualquier momento sin miedo: no hay estado compartido que se corrompa al retroceder. Puedes reproducir un bug paso a paso, exportar la secuencia de acciones, enviársela a un compañero y que la reproduzca idéntica. Esa reproducibilidad —el sueño de “envíame los pasos exactos”— es un regalo directo de la inmutabilidad.

Hay un matiz sutil que separa dos técnicas parecidas. Reproducir acciones vuelve a ejecutar los reducers desde un estado inicial; saltar a un estado guardado simplemente cambia el puntero al valor ya calculado. La primera exige reducers puros y deterministas; la segunda, solo que los estados sean inmutables. Ambas son gratis precisamente porque el estado nunca se mutó a espaldas de nadie.

El contrato que todo el ecosistema asume

🧮

Comparación en O(1)

Saber si el estado cambió es comparar dos punteros con Object.is, no recorrer el árbol. Toda la memoización descansa aquí.

↩️

Undo/redo por punteros

El historial es una pila de referencias a versiones, no de copias. Deshacer es mover un puntero, no reconstruir el pasado.

🕰️

Time-travel reproducible

Saltar a un estado pasado no tiene efectos secundarios: renderizar desde un valor inmutable siempre da el mismo resultado.

🧬

Renderizado predecible

React, los selectores y el React Compiler asumen lo mismo: si cambió, es otra referencia; si no, es exactamente la misma.

La identidad referencial es la viga maestra

Las tres superpotencias de este nivel son en realidad la misma idea vista desde tres ángulos. La comparación por referencia funciona porque un valor inmutable que no cambió conserva su identidad, y uno que cambió estrena identidad: la igualdad de punteros se vuelve un proxy fiable de la igualdad de contenido. El undo/redo funciona porque cada versión es un valor independiente que nadie puede corromper a posteriori, así que guardar el pasado es guardar referencias. El time-travel funciona porque saltar a una versión no tiene efectos secundarios: renderizar desde un valor inmutable siempre da el mismo resultado. La mutación destruye las tres de un solo golpe, porque destruye lo único que las sostiene: la promesa de que una referencia, una vez creada, significará siempre lo mismo. Cuando interiorizas esto dejas de ver la inmutabilidad como una restricción incómoda y empiezas a verla como lo que es: la decisión de diseño que convierte el estado en algo razonable, comparable y reproducible. Todo lo demás del track —selectores, máquinas de estado, flujo unidireccional, CRDTs— asume esta viga maestra ya puesta.

⚔️ Comprueba que la referencia es la verdad
  1. Crea un objeto de estado anidado, muta una propiedad profunda in situ y comprueba que prev === next sigue siendo true: has hecho un cambio invisible.
  2. Reescríbelo con actualización inmutable y verifica que next !== prev pero que las ramas no tocadas siguen compartiendo referencia con ===.
  3. Implementa Historia<S> con deshacer y rehacer, encadena diez acciones y cuenta cuántas copias reales del estado existen (pista: casi ninguna).
  4. Abre Redux DevTools o el inspector de Zustand en cualquier app, dispara varias acciones y salta hacia atrás; explica en voz alta por qué eso sería imposible sobre estado mutable.