Mutar el estado: el pecado original
Toda la maquinaria de Redux descansa sobre una promesa que el lenguaje no puede hacer cumplir: que nadie tocará el estado anterior. Esta lección explica por qué una mutación no produce un error sino un silencio, disecciona las cuatro grietas por las que sigue colándose incluso en bases de código que usan Immer de fábrica, y monta el arsenal de detección que convierte ese silencio en una excepción con la ruta exacta del campo culpable. Cierra con la distinción que casi nadie hace: mutar y no ver la interfaz actualizarse es el caso benigno, porque al menos falla; el caso maligno es mutar, devolver un objeto nuevo por encima y reescribir el pasado sin que nada parezca roto.
Redux no es una librería de gestión de estado: es un contrato de inmutabilidad con un poco de infraestructura alrededor. Todo lo que le da valor —la comparación de referencias que decide qué componente renderiza, el historial navegable de las DevTools, la reproducción de una sesión de usuario, la posibilidad de testear un reducer sin montar nada— cuelga de una sola promesa: que el estado anterior sigue exactamente como estaba. Y esa promesa la firma el programador, no el lenguaje. JavaScript no tiene inmutabilidad por defecto, const congela el enlace y no el valor, y push es una llamada perfectamente válida sobre un array que Redux considera sagrado. Por eso la mutación es el pecado original de este track: no es un error de principiante que se supera, es la tentación estructural que el propio sustrato ofrece en cada línea, y que solo se contiene con disciplina, herramientas y una comprensión precisa de qué se rompe exactamente cuando se cede.
- Explicar por qué una mutación no lanza un error sino que produce un silencio, y por qué el caso silencioso es el peor.
- Reconocer las cuatro grietas por las que la mutación entra en una base de código que ya usa Immer.
- Instrumentar el store para que la mutación deje de ser invisible y se convierta en una excepción con ruta exacta.
- Refactorizar el punto de mutación en lugar de parchear el síntoma en el componente que no renderiza.
La mutación no falla: enmudece
Empieza por lo que realmente ocurre en la máquina. Cuando escribes estado.lista.push(nuevo) dentro de un reducer sin Immer, la llamada devuelve un número, no lanza nada y hace su trabajo perfectamente: el array crece. El problema es que ese array es el mismo objeto en memoria que Redux ya tenía guardado como estado anterior. Si además devuelves ese mismo objeto de estado, el store guarda una referencia idéntica a la que ya tenía, el suscriptor compara con Object.is, concluye que nada cambió y no renderiza. El usuario ve una interfaz que no se entera de un dato que sí cambió. Es un bug desconcertante precisamente porque el estado en las DevTools se ve correcto: los datos están ahí, y la vista miente.
Ese es, sin embargo, el caso benigno, y conviene decirlo con todas las letras porque va contra la intuición. Un bug que se manifiesta es un bug que se arregla. El caso realmente peligroso es el híbrido, y es el que sobrevive años en producción: mutas una estructura interna y además devuelves un objeto nuevo en el nivel superior. La interfaz se actualiza —la referencia raíz cambió— y nada parece roto. Pero el objeto interno que mutaste está compartido con todas las instantáneas anteriores del historial, así que acabas de reescribir el pasado. Las DevTools te mostrarán estados antiguos que ya contienen el dato nuevo; el diff de la acción aparecerá vacío o incoherente; el viaje en el tiempo devolverá estados que jamás existieron. Has convertido tu herramienta de depuración en una fuente de desinformación, que es bastante peor que no tenerla.
flowchart TD A[reducer recibe el estado] --> B[copia o modifica en sitio] B -->|copia| C[referencia nueva] B -->|muta| D[misma referencia interna] C --> E[render correcto e historial intacto] D --> F[el suscriptor no ve cambio] D --> G[las instantaneas antiguas cambian tambien] G --> H[el pasado queda reescrito] style D fill:#f38ba8,color:#11111b style H fill:#f38ba8,color:#11111b style E fill:#a6e3a1,color:#11111b
Formulado como propiedad, el pacto es este: si nadie muta, entonces identidad distinta implica contenido distinto e identidad igual implica contenido igual, y comparar referencias se vuelve un sustituto correcto y baratísimo de comparar contenidos. La mutación rompe la segunda implicación. A partir de ahí, ninguna de las optimizaciones que dependen de la comparación por identidad —y son todas— puede seguir confiando en su propia respuesta.
Las cuatro grietas que Immer no tapa
La objeción razonable en 2026 es que esto ya está resuelto: Redux Toolkit trae Immer integrado, escribes estado.lista.push(nuevo) dentro de createSlice y obtienes inmutabilidad estructural gratis. Es cierto, y por eso el problema cambió de forma en lugar de desaparecer. Immer solo protege dentro del productor: el objeto que recibe tu reducer es un borrador envuelto en un proxy, y la magia termina en el instante en que ese productor retorna. Todo lo que quede fuera de ese perímetro es JavaScript crudo otra vez, con la agravante de que el equipo ya interiorizó que mutar está permitido.
Fuera del productor
Un thunk que llama a getState recibe el estado real, no un borrador. Un sort o un reverse sobre esa lista muta el store por la espalda, sin acción, sin registro y sin render.
Mutar y además retornar
Dentro de un productor debes hacer una cosa u otra: modificar el borrador o devolver un valor nuevo. Hacer las dos lanza un error en tiempo de ejecución, y el fallo aparece lejos de la línea culpable.
El borrador que se escapa
Guardar el borrador —o una rama suya— en una variable de módulo, una caché o el valor de retorno deja una referencia a un proxy revocado. Tocarla después lanza, y el mensaje no dice quién la guardó.
Lo que Immer no envuelve
Instancias de clase sin marcar como inmerables, Map y Set sin el plugin correspondiente, o cualquier objeto no plano metido en el estado: Immer los deja pasar tal cual y mutarlos muta el original.
// Grieta 1: la mas frecuente. sort y reverse mutan el array recibido.
const ordenar = () => (dispatch, getState) => {
const lista = getState().tareas.lista
lista.sort((a, b) => a.orden - b.orden) // muta el store sin despachar nada
dispatch(listaOrdenada(lista))
}
// Correcto: copia antes de ordenar, y deja que el reducer decida.
const ordenarBien = () => (dispatch, getState) => {
const ids = [...getState().tareas.lista].sort((a, b) => a.orden - b.orden)
dispatch(listaOrdenada(ids))
}
// Grieta 2: mutar el borrador y ademas retornar. Immer lo rechaza.
reducers: {
reiniciada(estado) {
estado.cargando = false
return estadoInicial // error: no puedes hacer ambas cosas
},
}
Hay una quinta variante que no es de Immer sino de disciplina y aparece en casi todos los proyectos: el objeto de estado inicial declarado a nivel de módulo y compartido entre pruebas o entre instancias del store. Basta una mutación en un test para que el siguiente arranque con un estado contaminado, y el fallo aparecerá en el archivo equivocado y solo cuando el orden de ejecución cambie. Es el mismo pecado con vestimenta de infraestructura.
Hacerla visible antes que el usuario
La defensa no es pedir atención: es instrumentar. configureStore monta en desarrollo un middleware que recorre el árbol de estado antes y después de cada despacho, compara ambas fotos y lanza si detecta que algo cambió fuera de un reducer. Su mensaje incluye la ruta exacta del campo culpable, del tipo tareas.lista.0.hecha, que es la diferencia entre una tarde de depuración y una corrección de treinta segundos. Ese mismo chequeo tiene un coste real —recorre todo el árbol dos veces por acción—, así que en estados grandes se acota con rutas ignoradas o se ajusta el umbral de aviso, nunca se desactiva en bloque: apagarlo es exactamente renunciar al único detector automático que existe.
// Ajustar el guardia en lugar de apagarlo cuando el estado es grande.
const store = configureStore({
reducer,
middleware: (getDefault) =>
getDefault({
immutableCheck: { ignoredPaths: ['mapa.tiles'], warnAfter: 64 },
serializableCheck: { ignoredPaths: ['carga.archivo'] },
}),
})
// Prueba generica: un slice no puede tocar el estado que recibe.
it('no muta el estado anterior', () => {
const anterior = estadoInicial
const copia = structuredClone(anterior)
reducer(anterior, articuloAgregado({ id: 'a' }))
expect(anterior).toEqual(copia)
})
La segunda capa es el congelado. Immer congela por defecto el estado que produce, de modo que una mutación posterior sobre él lanza en modo estricto en lugar de tener éxito. Como los módulos ES son estrictos siempre, en la práctica obtienes una excepción en el punto exacto de la infracción y no un fallo silencioso doscientas líneas más allá. La tercera capa es el test: clona en profundidad el estado antes de despachar, despacha y afirma que el clon y el estado anterior siguen siendo iguales. Es una prueba que se escribe una vez por slice y captura la clase entera del error.
El instinto es prohibir la reasignación de parámetros con no-param-reassign y su opción de propiedades, que es justamente la regla que atrapa estado.x = 1. El problema es que dentro de createSlice esa mutación aparente no solo está permitida: es el estilo idiomático. Una configuración ingenua llena de avisos falsos precisamente el sitio donde el código es correcto, y el equipo acaba desactivando la regla entera, con lo que deja de vigilar los sitios donde sí importa. La configuración útil exime el parámetro de estado de los productores de Immer y mantiene la prohibición en todo lo demás. La paradoja merece entenderse en vez de sufrirse: la misma sintaxis significa mutación real fuera del productor y descripción declarativa de un cambio dentro de él, y ninguna herramienta puramente sintáctica puede distinguirlas sin que tú le digas dónde está la frontera.
El refactor y su error simétrico
Detectada la mutación, la reacción instintiva es la copia defensiva: clonar en profundidad todo lo que entra y todo lo que sale, por si acaso. Funciona, elimina el bug y produce un problema nuevo del mismo tamaño en el lado contrario, así que conviene verlo antes de cometerlo. Un clon profundo crea objetos nuevos para todas las ramas del árbol, incluidas las que nadie tocó. A partir de ahí ningún selector memoizado vuelve a acertar, ningún componente memoizado vuelve a evitar su render y ninguna comparación por referencia vuelve a ser informativa: has restaurado la corrección del pasado a cambio de destruir la propiedad que hacía barato el presente. La mutación produce identidades iguales para contenidos distintos; el clon profundo produce identidades distintas para contenidos iguales. Ambos rompen la correspondencia, solo que en direcciones opuestas.
La respuesta correcta está en el medio y tiene nombre: compartición estructural. Copiar únicamente el camino desde la raíz hasta el nodo que cambia y reutilizar tal cual todas las ramas intactas. Es exactamente lo que produce el spread anidado escrito a mano, lo que Immer genera desde el borrador y lo que hace que un estado de decenas de miles de nodos pueda actualizarse creando media docena de objetos. La regla operativa que se deriva de ello es sencilla de recordar: nunca clones lo que no vas a cambiar, y nunca modifiques lo que no acabas de crear.
// Error simetrico: todo cambia de identidad, la memoizacion deja de servir.
const siguiente = structuredClone(estado)
siguiente.tareas.lista[0].hecha = true
// Comparticion estructural: solo el camino tocado es nuevo.
const siguiente = {
...estado,
tareas: {
...estado.tareas,
lista: estado.tareas.lista.map((t, i) =>
i === 0 ? { ...t, hecha: true } : t,
),
},
}
Queda un último frente que casi ningún equipo cierra y que explica las mutaciones más difíciles de rastrear: la frontera de entrada. Los datos que llegan por red, los que se rehidratan de almacenamiento y los que produce una librería de terceros entran en el estado sin que nadie los haya declarado planos ni congelados, y a menudo son objetos que alguien más conserva por referencia. Si el mismo objeto que guardaste en el store sigue vivo en el módulo que hizo la petición, en una caché de la capa de red o en el cierre de un manejador, cualquiera de ellos puede mutarlo más tarde sin pasar por un dispatch, y ningún guardia del store lo verá venir porque la escritura ocurre entre despachos. Normalizar y copiar en el punto de entrada —una sola vez, al convertir la respuesta en carga útil de la acción— cierra esa puerta de forma definitiva y barata.
Conviene resistir la lectura utilitaria de esta prohibición, la que la presenta como un truco para que React detecte cambios rápido. Si eso fuera todo, cualquier mecanismo de observación profunda —un proxy que emita eventos, un sistema de señales, una comparación estructural— la volvería innecesaria, y sin embargo los sistemas que adoptan esos mecanismos pierden algo que ninguno recupera. Lo que la inmutabilidad compra no es velocidad de detección sino la existencia misma del pasado como entidad manipulable. En un modelo mutable solo existe un estado, el actual, y la historia es una narración que reconstruyes a partir de registros; en un modelo inmutable cada estado que la aplicación tuvo sigue existiendo como valor, y la historia deja de ser una narración para convertirse en una estructura de datos que puedes recorrer, comparar, serializar y reproducir. De ahí sale todo lo demás, y de una sola vez: el viaje en el tiempo no es una función que alguien programó sino la consecuencia trivial de que los estados anteriores nunca se destruyeron; el informe de error que adjunta la secuencia exacta de acciones del usuario funciona porque reproducirlas sobre el estado inicial da con certeza el mismo resultado; el deshacer no necesita lógica inversa porque basta con volver a apuntar a un valor que sigue ahí; y la comparación por referencia es correcta porque nadie puede haber cambiado el contenido sin cambiar la identidad. Cuando mutas, no rompes una optimización: colapsas la línea temporal en un único punto y destruyes retroactivamente instantáneas que otros componentes del sistema siguen creyendo válidas. Esa es la razón por la que el error es tan difícil de diagnosticar y tan barato de cometer: sus consecuencias no aparecen donde ocurrió, sino en cualquier lugar que hubiera confiado en la promesa. Y por eso la disciplina no se negocia por comodidad: no estás protegiendo un dato, estás protegiendo la capacidad de tu programa de tener memoria.
- Busca en toda la base de código las llamadas a
sort,reverse,splice,pushypopque no estén dentro de un productor de Immer, y clasifícalas en seguras o sospechosas. - Provoca a propósito el caso benigno: muta y devuelve el mismo estado, y comprueba que la interfaz se queda congelada aunque los datos hayan cambiado.
- Provoca ahora el caso maligno: muta una rama interna y devuelve un objeto nuevo arriba. Abre las DevTools y verifica que las instantáneas anteriores también cambiaron.
- Activa el chequeo de inmutabilidad y mide cuánto tiempo añade por despacho en tu estado real; decide con ese número si necesitas acotar rutas en lugar de desactivarlo.
- Escribe una prueba genérica que, para cada slice, clone el estado antes de despachar y afirme que el original permanece intacto.
- Ajusta la regla de lint de reasignación de propiedades para que exima los productores de Immer y vigile todo lo demás; ejecútala y anota cuántos hallazgos reales aparecen.