wandres.dev
CREATEMUTABLE · el store mutable

Los peligros de createMutable: reactividad implícita y mutación oculta

El reverso de la comodidad: cuando cualquier state.x = 1 en cualquier archivo es un disparador reactivo, la escritura deja de ser localizable y el flujo de datos pierde su forma. Mutaciones a distancia por referencias compartidas, efectos que se disparan sin causa visible, depuración sin censo de escrituras, y la razón de fondo por la que el equipo de Solid desaconseja el primitivo por defecto.

⏱ 16 min

La misma propiedad que hace a createMutable tan cómodo —que cualquier asignación en cualquier sitio simplemente funcione— es la que lo hace peligroso. Cuando la escritura no tiene nombre ni puerta, deja de ser un evento y se disuelve en el flujo del programa: un efecto se dispara y no hay forma directa de saber qué línea lo provocó, un objeto que entregaste hace tres módulos muta el estado a tus espaldas, y el censo de “dónde cambia esto” que un store te regalaba se evapora. Esta lección no es un catálogo de defectos: es el análisis de por qué el equipo de Solid marca este primitivo como una herramienta de nicho y no como el camino por defecto.

🎯 Al terminar esta lección sabrás
  • Nombrar el problema central: la reactividad implícita en el punto de escritura.
  • Reconocer la mutación a distancia que nace de compartir referencias al proxy.
  • Ver por qué depurar y refactorizar se vuelve más caro sin un censo de escrituras.
  • Entender el argumento de diseño por el que Solid lo desaconseja por defecto.

La escritura sin nombre

En un store, encontrar todos los lugares donde el estado cambia es una búsqueda de texto: setStore. Ese nombre es un ancla —cada escritura pasa por él, así que enumerarlas es trivial y auditarlas, posible. En un createMutable, la escritura es una asignación ordinaria, indistinguible de mutar cualquier objeto local. No hay término que buscar, porque state.x = 1, usuario.nombre = n y carrito.total += p no comparten ninguna marca sintáctica que los delate como reactivos.

// createStore: el censo de escrituras es buscable
setStore("usuario", "nombre", "Ada");   // grep setStore -> todas las mutaciones

// createMutable: la escritura se camufla entre asignaciones cualesquiera
state.usuario.nombre = "Ada";           // indistinguible de mutar un objeto local

La consecuencia es que el grafo de “quién cambia qué” deja de vivir en el código y pasa a vivir en tu memoria. Mientras el estado es pequeño y de un solo autor, esa memoria basta. Cuando crece, o cuando el autor eres tú-de-hace-seis-meses, la ausencia de ancla se cobra su precio: cada depuración empieza por reconstruir a mano el mapa de escrituras que el store te habría dado gratis.

Mutación a distancia por referencia compartida

El peligro se agrava porque el proxy es un objeto, y los objetos se pasan por referencia. En cuanto entregas el mutable —o una rama anidada suya— a una función, un componente hijo o una librería, le concedes la capacidad de escribir en tu estado desde un lugar arbitrario del programa. No es un permiso que hayas declarado: es una consecuencia automática de compartir la referencia.

const state = createMutable({ carrito: { total: 0, items: [] as any[] } });

function registrar(destino: { total: number }) {
  // esta funcion ni sabe que 'destino' es reactivo
  destino.total = 999; // muta el estado global desde aqui
}

registrar(state.carrito); // accion a distancia: el total cambia, y la vista reacciona

Esto es acción a distancia: un efecto en la interfaz se dispara por una línea escrita en un módulo que no tiene relación aparente con la vista. El vínculo entre causa y efecto existe —la referencia compartida— pero es invisible en el punto de lectura. Depurar se convierte en rastrear quién más tiene un alias del objeto, una pregunta que el lenguaje no responde y que el store, al canalizar toda escritura por su setter, hacía innecesaria.

flowchart TD
M[componente A] -->|entrega la referencia| F[funcion utilitaria]
L[libreria de terceros] -->|recibe el proxy| G[bucle interno]
F -->|destino.total = 999| S[estado mutable compartido]
G -->|cuerpo.x += vx| S
S --> E[efectos de la vista se disparan]
E --> Q[por que corrio esto]
style S fill:#f38ba8,color:#11111b
style Q fill:#f9e2af,color:#11111b

Sin un censo de escrituras, además, tres actividades cotidianas se encarecen. Depurar, porque no puedes partir del conjunto de mutaciones para acotar la causa de un cambio inesperado. Refactorizar, porque mover o extraer código que muta el proxy puede alterar el orden o el momento de escrituras que nadie había catalogado como tales. Y razonar en equipo, porque el contrato “así cambia este estado” no está escrito en ninguna parte que puedas señalar en una revisión.

// Un bug clasico del mutable: aliasing accidental
const config = createMutable({ tema: { color: "azul" } });
const tema = config.tema;        // alias de la rama anidada
tema.color = "rojo";             // muta el estado global sin mencionar 'config'
// meses despues, alguien lee 'tema' creyendo que es una copia local... no lo es

Ninguno de estos costes aparece en el prototipo. Todos aparecen en el mantenimiento, que es donde vive la mayor parte de la vida de un programa. Es la firma típica de una herramienta que optimiza la escritura inicial a expensas de la comprensión posterior: rápida de teclear, lenta de sostener.

🕳️

Escritura sin ancla

No hay setStore que buscar. El conjunto de mutaciones no es enumerable por búsqueda, solo reconstruible de memoria.

📡

Acción a distancia

Compartir el proxy concede permiso de escritura implícito. Un efecto se dispara desde un módulo sin relación visible con la vista.

🪞

Aliasing silencioso

Una rama anidada asignada a una variable es un alias vivo, no una copia. Mutarla cambia el estado global sin nombrarlo.

El servidor: mutación que se filtra entre peticiones

Hay un escenario donde el peligro deja de ser de mantenimiento y se vuelve un fallo de corrección grave: el renderizado en servidor. Un createMutable definido a nivel de módulo se crea una sola vez por proceso y se comparte entre todas las peticiones que ese proceso atiende. Si una petición muta el proxy durante su render, esa mutación persiste y contamina la siguiente petición —de otro usuario—. Es una fuga de estado entre sesiones, la clase de bug que expone datos de un usuario a otro.

// PELIGRO en SSR: modulo compartido entre peticiones
export const sesion = createMutable({ usuario: null as string | null });

// Si un componente muta esto durante el render en servidor,
// el siguiente request hereda el usuario del anterior
sesion.usuario = "ada"; // se filtra fuera de esta peticion

El estado a nivel de módulo es problemático en SSR con cualquier primitivo, pero createMutable lo agrava porque la mutación es implícita: no hay un setStore que delate dónde ocurrió la escritura que filtró el dato. La regla es crear el estado dentro del árbol de componentes o de un contexto por petición, nunca como singleton de módulo mutable. Este matiz, invisible en desarrollo con una sola pestaña, es de los que solo aparecen en producción bajo concurrencia.

Por qué el equipo lo desaconseja por defecto

La documentación de Solid presenta createMutable con una advertencia explícita: es fácil de usar mal y puede llevar a código difícil de razonar, así que no es la vía recomendada para el estado ordinario. El argumento no es que su motor sea inferior —es el mismo de createStore— sino que su contrato con el programador es más débil. La reactividad de grano fino se construye sobre la idea de que las dependencias y los cambios sean explícitos y localizables; createMutable sacrifica esa localizabilidad de la escritura a cambio de ergonomía, y ese sacrificio va en contra del grano de la biblioteca.

⚠️
Los frameworks que normalizaron la mutación cargan con años de esta deuda

La mutación reactiva libre no es una idea nueva ni fallida en sí: es el modelo de Vue con sus reactive y de MobX con sus observables, y ha sostenido aplicaciones enormes. Pero también es la fuente de su clase más característica de bugs —mutaciones a distancia, reactividad que se dispara sin causa aparente, estado que cambia desde donde no lo esperas—. Solid tomó nota y eligió lo contrario por defecto: escritura canalizada y explícita. createMutable es la puerta de vuelta a ese modelo, disponible para cuando lo necesitas, pero abierta con la advertencia de que traes contigo la deuda que Solid diseñó su store para evitar.

El peligro de createMutable no es que falle, es que funciona sin dejar rastro

Lo desconcertante de este primitivo es que nunca se rompe de forma escandalosa. No lanza excepciones, no pierde reactividad como un signal mal mutado, no te avisa en desarrollo como el store. Hace exactamente lo que le pides: propaga cada mutación, venga de donde venga. Y ahí está justo el problema, porque un sistema que obedece toda escritura sin registrar de dónde vino traslada la carga entera de la trazabilidad al programador, y esa carga no crece de forma lineal sino con el cuadrado de las referencias que has repartido. La reactividad de grano fino de Solid fue diseñada para que el sistema hiciera un trabajo concreto por ti: mantener explícito y localizable el grafo de dependencias y de cambios, de modo que “qué depende de qué” y “qué cambia esto” fueran preguntas que el código responde, no tu memoria. Cada otro primitivo honra ese contrato —los signals canalizan la escritura por su setter, los stores por setStore, los memos declaran sus fuentes al leerlas—. createMutable es el único que lo rompe a propósito, y por eso el equipo lo marca como excepción y no como norma. No porque produzca resultados incorrectos, sino porque produce resultados correctos por caminos que dejan de ser inspeccionables. La lección estratégica es que la comodidad de escritura y la trazabilidad de un sistema reactivo están en tensión estructural, no accidental: no puedes maximizar ambas a la vez, porque hacer la escritura invisible es precisamente lo que la hace cómoda y lo que la hace no rastreable. Elegir createMutable es elegir, con los ojos abiertos, un punto de esa tensión donde la ergonomía vale más que el rastro. La lección siguiente delimita los pocos lugares donde esa elección es defendible.

⚔️ Provoca y diagnostica el peligro
  1. Comparte una rama anidada de un createMutable con una función que la mute y observa un efecto de la vista dispararse “desde ninguna parte”.
  2. Intenta enumerar, solo con búsqueda de texto, todos los lugares donde ese estado cambia; anota por qué no puedes.
  3. Reproduce un bug de aliasing asignando una rama a una variable y mutándola; explica por qué no era una copia.
  4. Reescribe el mismo estado con createStore y confirma que ahora setStore te da el censo de escrituras que antes faltaba.
  5. Redacta en una frase el contrato que createMutable debilita respecto al resto de primitivos de Solid.