wandres.dev
VALTIO Y NANOSTORES · proxy y agnóstico

Valtio: el proxy mutable por dentro

El Nivel 6 presentó Valtio como la rareza que te deja escribir state.x = 1 y ver reaccionar la interfaz. Esta lección abre la caja: qué trampa del Proxy de ES6 intercepta cada asignación, cómo se propaga la invalidación desde una hoja anidada hasta la raíz, por qué existe un contador de versiones, cómo se agrupan las mutaciones de un mismo tick en una sola notificación y qué valores quedan deliberadamente fuera del envoltorio. Entender el mecanismo convierte la magia en ingeniería y explica de una vez por qué ciertos bugs aparecen y otros son imposibles.

⏱ 19 min

Hay dos maneras de usar Valtio. La primera es aceptar el trato: mutas un objeto y la interfaz se actualiza sola. Funciona, y para la mayoría de las aplicaciones basta. La segunda es exigir saber por qué funciona, porque el día que algo no reacciona necesitas un modelo mental de lo que ocurre bajo la superficie, no un encogimiento de hombros. Esta lección toma la segunda vía. Valtio no tiene compilador, ni transformación de código, ni paso de construcción: todo lo que hace cabe en tres mecanismos que el lenguaje regaló en ES6 y que la librería combina con una economía admirable. Al terminar deberías poder reimplementar una versión mínima de proxy en una tarde, y eso es exactamente lo que separa a quien usa una herramienta de quien la comprende.

🎯 Al terminar esta lección sabrás
  • Explicar qué hacen las trampas de lectura y escritura del Proxy que Valtio instala sobre tu objeto.
  • Seguir la propagación de una mutación anidada desde la hoja hasta la raíz y hasta los suscriptores.
  • Entender el papel del contador de versiones y del agrupamiento de notificaciones en un mismo tick.
  • Reconocer qué valores quedan fuera del envoltorio y por qué esa frontera es deliberada.

La asignación que dispara un sistema

El punto de partida es engañosamente simple. proxy recibe un objeto plano y devuelve otro objeto que se comporta igual ante cualquier operación del lenguaje, salvo que registra lo que ocurre:

import { proxy, subscribe } from 'valtio'

export const tienda = proxy({
  usuario: { nombre: 'Ada', direccion: { ciudad: 'Londres' } },
  lineas: [] as Array<{ id: string; cantidad: number }>,
})

tienda.usuario.direccion.ciudad = 'Cambridge'
tienda.lineas.push({ id: 'a1', cantidad: 2 })

Ninguna de esas dos líneas parece especial, y ese es justamente el logro. Lo que ocurre por debajo sí lo es. Cuando proxy envuelve el objeto raíz, no envuelve solo la raíz: recorre las propiedades y, cada vez que encuentra un objeto o un array, lo envuelve también, de forma perezosa, en el momento en que alguien accede a él por primera vez. Así, tienda.usuario no devuelve el objeto original sino su propio proxy, y tienda.usuario.direccion devuelve el proxy del proxy. Para cuando la asignación a ciudad se ejecuta, quien la recibe es la trampa de escritura del proxy más interno, que sabe quién es su padre.

Esa cadena de padres es el primer mecanismo que hay que interiorizar. Cada proxy anidado guarda una referencia a los proxies que lo contienen, de modo que una escritura en la hoja puede caminar hacia arriba notificando a cada ancestro. Es la razón por la que mutar tienda.usuario.direccion.ciudad despierta a un componente que solo lee tienda.usuario: el cambio no se queda encerrado en la hoja, sube. Y es también la razón por la que un objeto extraído del proxy y guardado aparte sigue siendo reactivo, porque lo que guardaste no era el objeto sino su proxy, con su cadena de ascendencia intacta.

El caso del array merece un párrafo propio porque suele desconcertar. Un push no es una asignación sino un método, pero por dentro escribe en el índice y actualiza la propiedad length, y ambas operaciones pasan por la trampa de escritura. De ahí que mutaciones de array que en Redux exigían producir una copia entera aquí sean literalmente el método del lenguaje. Lo mismo vale para splice, sort o la asignación directa a length para vaciar.

💡
El proxy es identidad, no copia

Un error de intuición frecuente es imaginar que proxy clona tu objeto. No lo hace: envuelve el mismo objeto, y las mutaciones llegan al original. Por eso mantener una referencia al objeto crudo que le pasaste y mutarlo directamente es la forma más limpia de romperlo todo, porque esa escritura no pasa por ninguna trampa y nadie se entera. La regla práctica es no conservar nunca el objeto original: crea el proxy en la misma expresión en que construyes el literal y olvida que existía otra cosa.

Versiones, invalidación y notificación en lote

La trampa de escritura no re-renderiza nada. Lo único que hace es marcar y avisar, y toda la eficiencia de Valtio nace de esa modestia. Cada proxy mantiene un número de versión que se incrementa cuando algo dentro de él cambia, y la propagación hacia arriba incrementa la versión de cada ancestro. Una versión distinta significa este subárbol ya no es el mismo, y comparar versiones es comparar enteros, la operación más barata que existe.

flowchart TD
A[asignacion en hoja anidada] --> B[trampa set del proxy interno]
B --> C[incrementa version del nodo]
C --> D[propaga hacia los ancestros]
D --> E[marca la raiz como cambiada]
E --> F[encola notificacion para el microtask]
F --> G[suscriptores y snapshots nuevos]
style B fill:#f9e2af,color:#11111b
style E fill:#a6e3a1,color:#11111b
style G fill:#89b4fa,color:#11111b

El segundo mecanismo es el agrupamiento. Si un manejador muta doce campos seguidos, notificar doce veces sería absurdo: los suscriptores verían once estados intermedios que a nadie interesan. Valtio encola la notificación y la ejecuta una sola vez cuando la pila de sincronía se vacía, de modo que los doce cambios se ven como uno. Esto explica un comportamiento que sorprende al depurar: tras mutar, leer el snapshot en la línea siguiente puede darte todavía la foto anterior, porque la nueva aún no se ha producido. La verdad viva está en el proxy; la foto llega después.

subscribe(tienda, () => {
  console.log('un solo aviso para las tres mutaciones')
})

tienda.usuario.nombre = 'Grace'
tienda.usuario.direccion.ciudad = 'Nueva York'
tienda.lineas.length = 0

El tercer mecanismo es la instantánea con compartición estructural. Cuando alguien pide una foto, Valtio no copia el árbol entero: reutiliza las ramas cuya versión no cambió y solo construye objetos nuevos para las que sí. El resultado es un objeto congelado cuyas partes intactas conservan la misma identidad que en la foto anterior, y esa identidad estable es lo que permite que las comparaciones por referencia funcionen igual que en un mundo inmutable puro. Aquí está la reconciliación completa: escribes mutando, el sistema razona con inmutabilidad y ninguna de las dos partes paga el precio de la otra.

⚠️
Sincronía aparente, asincronía real

La consecuencia práctica del agrupamiento es que Valtio es síncrono para escribir y diferido para observar. Si escribes una prueba que muta el proxy y comprueba de inmediato que el componente se actualizó, fallará, y no porque la librería esté rota sino porque la notificación aún no ha corrido. Espera al siguiente microtask, o usa las utilidades de espera de tu librería de pruebas. Confundir este desfase con un bug ha costado más horas de depuración que cualquier otro rasgo del diseño.

La frontera del envoltorio

No todo lo que metes en un proxy queda envuelto, y conocer la frontera evita la mitad de las sorpresas. Los objetos planos y los arrays se envuelven; los primitivos no lo necesitan porque se reemplazan enteros; y hay una tercera categoría, la de los valores que el envoltorio no sabe tratar o no debe tratar, que exige decisión explícita.

🪶

Se envuelve solo

Objetos literales y arrays, a cualquier profundidad y de forma perezosa. Mutar en el nivel doce es tan reactivo como mutar en la raíz.

🧭

Necesita utilidad

Map y Set no son objetos planos y sus métodos no pasan por la trampa. proxyMap y proxySet los cubren con envoltorios propios.

🧱

Se marca con `ref`

Instancias de clase, nodos del DOM, buffers grandes. ref le dice al proxy que no rastree ni copie ese valor en las instantaneas.

🚫

No debe entrar

Funciones con estado interno, promesas vivas, recursos que se cierran. Lo que no es dato no gana nada por ser observado.

La utilidad ref merece una lectura más fina que la de simple válvula de escape. Su efecto no es solo evitar el coste del envoltorio: es declarar que ese valor tiene identidad estable y que la instantánea debe llevarlo tal cual, sin congelarlo ni clonarlo. Sin ref, meter una instancia de clase en un proxy produce un objeto envuelto cuyos métodos siguen funcionando pero cuya identidad ya no coincide con la original, y cualquier comparación con instanceof o con una referencia guardada fuera falla de formas difíciles de rastrear.

import { proxy, ref } from 'valtio'

const escena = proxy({
  camara: { x: 0, y: 0 },
  lienzo: ref(document.createElement('canvas')),
})

escena.camara.x += 10

Las utilidades proxyMap y proxySet merecen una nota, porque su existencia enseña algo sobre los límites de la intercepción. Un Proxy intercepta operaciones sobre propiedades, y los métodos de Map o de Set no operan sobre propiedades sino sobre un almacén interno del motor al que ninguna trampa llega. Por eso no basta con envolver: hay que reimplementar la estructura sobre datos que sí sean propiedades observables. Es un recordatorio de que la reactividad por intercepción no es universal, sino que alcanza exactamente hasta donde llegan las operaciones que el lenguaje expone como trampas.

Hay una última frontera, esta vez del lenguaje y no de la librería. Proxy es una primitiva de ES6 que no se puede simular sin él, lo que significa que Valtio no funciona en motores anteriores a 2015 y que ciertas operaciones exóticas sobre objetos congelados o con descriptores no configurables pueden comportarse de forma distinta a la esperada. En 2026 esto es folclore para casi todo el mundo, pero conviene saber que la magia se apoya en una primitiva concreta y no en un truco de compilación.

Por qué este mecanismo produce este estilo

Comprender el motor explica el estilo de código que Valtio premia. Como la reactividad nace de interceptar el acceso, no hace falta declarar dependencias, y por tanto no hay selectores que memoizar ni listas de dependencias que mantener sincronizadas. Como la escritura es una asignación, la lógica de dominio puede vivir en funciones sueltas que mutan el proxy y no necesitan conocer React en absoluto. Y como las instantáneas son inmutables y estructuralmente compartidas, la frontera con el render mantiene todas las garantías que la ortodoxia inmutable prometía.

Ese estilo tiene una forma canónica que conviene fijar desde el principio, porque la libertad de mutar desde cualquier sitio degenera rápido si nadie la acota:

import { proxy } from 'valtio'

export const sesion = proxy({ usuario: null as string | null, cargando: false })

export async function iniciar(correo: string) {
  sesion.cargando = true
  try {
    sesion.usuario = await autenticar(correo)
  } finally {
    sesion.cargando = false
  }
}

Fíjate en que la función asíncrona muta el proxy en tres momentos distintos y ninguno exige devolver nada ni notificar a nadie. Como la notificación se agrupa por tick, las dos escrituras del camino feliz que caen en el mismo tick se ven como una, y las que caen tras un await producen su propio aviso, que es justo el comportamiento deseado: la vista ve cargando, y después ve el resultado, sin estados intermedios espurios.

De ahí nace un patrón que envejece bien: un módulo que exporta el proxy y un puñado de funciones que lo mutan, sin ninguna importación de React. Ese módulo es comprobable con pruebas puras, reutilizable desde un trabajador o desde el servidor, y sustituible sin tocar la vista. La lección siguiente aborda la otra mitad del contrato, la de lectura, donde useSnapshot convierte esa verdad viva en fotos que el render puede consumir con seguridad.

La intercepción convierte la declaración en observación

Todo gestor de estado responde a la misma pregunta: cómo sabe el sistema qué debe volver a ejecutar cuando algo cambia. Durante una década la respuesta unánime del ecosistema fue que el programador lo declare, y de esa premisa nacieron los selectores de Redux, las listas de dependencias de los hooks, los get de Jotai y las funciones de igualdad que tantas veces se escriben mal. La declaración parecía inevitable porque el sistema, se asumía, no puede leer la mente de tu componente. Valtio demuestra que no necesita leerla: le basta con mirar. El Proxy de ES6 permite interceptar cada lectura y cada escritura, y cuando cada acceso se registra, la dependencia deja de ser algo que declaras y pasa a ser algo que el sistema observa. Esa mudanza desde la declaración hacia la observación es el mismo movimiento que hicieron los signals, y no es casualidad que ambos hayan florecido en la misma década: son dos encarnaciones de la idea de que el grafo de dependencias es un hecho descubrible del programa y no una anotación que el humano deba mantener a mano. El precio es real y conviene nombrarlo sin adornos. La observación ocurre en tiempo de ejecución, así que ningún compilador puede avisarte cuando la rompes leyendo del sitio equivocado; el envoltorio tiene un coste por acceso, minúsculo pero no nulo; y la depuración exige entender el mecanismo, porque los síntomas de una violación de la regla son mudos. Lo que se compra a cambio es la desaparición de una clase entera de errores: los que nacen de que la dependencia declarada y la dependencia real dejaron de coincidir. Y esa clase de error, la del selector desactualizado y la del array de dependencias incompleto, ha sido durante años una de las fuentes más caras de bugs sutiles en el frontend. Que una primitiva del lenguaje llevara desde 2015 esperando a que alguien la usara para esto es la parte que más debería inquietarte: la mayoría de la ceremonia que aceptamos como necesaria no lo era, solo estaba a la espera de que alguien imaginara otra cosa.

⚔️ Reconstruye el mecanismo con tus manos
  1. Escribe un proxy con tres niveles de anidamiento y suscríbete a la raíz. Muta la hoja más profunda y confirma que el suscriptor de la raíz se entera: acabas de observar la propagación de ancestros.
  2. Muta cinco campos seguidos en la misma función y cuenta cuántas veces corre el suscriptor. Explica el resultado en términos del agrupamiento por tick.
  3. Extrae tienda.usuario a una variable, pásala a otra función y mútala allí. Verifica que sigue siendo reactivo y razona por qué la referencia que extrajiste no era el objeto crudo.
  4. Guarda una referencia al literal original antes de envolverlo y mútalo directamente. Observa que nadie reacciona y usa ese fallo para fijar la regla de no conservar el objeto crudo.
  5. Mete una instancia de clase en el proxy sin ref y comprueba qué ocurre con instanceof y con la identidad. Repite con ref y compara.
  6. Implementa una versión mínima de la idea en veinte líneas: un Proxy con trampa de escritura que incremente una versión y llame a una lista de suscriptores. Compárala con lo que hace la librería y anota qué te falta.