wandres.dev
EL POST-REDUX · Zustand, Jotai, Valtio

Valtio: el proxy mutable que se vuelve reactivo

Zustand y Jotai comparten el dogma de la inmutabilidad; Valtio lo rompe a propósito. Envuelve tu estado en un proxy de ES6 y te deja escribir state.x = 1 como si fuera un objeto cualquiera: la UI reacciona sola. Por debajo, el proxy registra qué lee cada componente y qué se escribe, y sirve instantáneas inmutables al render con optimización automática. Es la ergonomía de lo mutable con las garantías de lo inmutable, y la elección natural para código imperativo.

⏱ 17 min

Zustand y Jotai comparten un dogma heredado de Redux: el estado es inmutable, se sustituye y no se muta. Valtio, el tercer hijo de Poimandres y también de Daishi Kato, rompe ese dogma a propósito y con elegancia. Envuelve tu estado en un proxy de ES6 y te devuelve un objeto que mutas con la naturalidad de cualquier variable: escribes state.count = 1 y la interfaz reacciona sola, sin set, sin acciones, sin actualizadores. La magia no es magia: es el Proxy del lenguaje interceptando lecturas y escrituras. Y el truco maestro es que, de puertas afuera, todo sigue siendo inmutable. Este capítulo explica cómo Valtio reconcilia dos mundos que parecían enemigos.

🎯 Al terminar esta lección sabrás
  • Crear estado mutable con proxy y mutarlo directamente desde cualquier parte del código.
  • Consumirlo en React con useSnapshot y entender el tracking de accesos que optimiza el render.
  • Interiorizar la regla de oro: leer de la instantánea, escribir en el proxy.
  • Situar el modelo mutable frente al inmutable y al atómico, y reconocer el código imperativo donde brilla.

Mutar sin culpa

proxy recibe un objeto y devuelve una versión observada de él. A partir de ahí, lo tratas como lo que parece: un objeto mutable, anidado, que modificas por asignación directa desde donde quieras, dentro o fuera de React:

import { proxy } from 'valtio'

export const estado = proxy({ contador: 0, usuario: { nombre: 'Ada' } })

// Mutas directamente, en cualquier funcion, sin ceremonia:
estado.contador += 1
estado.usuario.nombre = 'Grace'

No hay reductor, ni acción, ni copia inmutable que construir. La asignación anidada estado.usuario.nombre —que en Redux exigía esparcir dos niveles de objeto a mano y en Zustand pedía Immer— aquí es una línea que dice exactamente lo que hace. Por debajo, el Proxy de ES6 instala trampas sobre las operaciones de lectura y escritura: cuando asignas, la trampa de escritura marca el estado como cambiado y programa la notificación a los suscriptores. Y el proxy es profundo: los objetos y arrays anidados se envuelven también, de modo que mutar en cualquier nivel de profundidad es igual de reactivo que mutar en la raíz.

Aunque puedes mutar el proxy desde cualquier parte, la práctica que envejece bien es colocar las acciones junto al estado, como funciones que mutan el proxy y que la UI invoca sin conocer los detalles:

import { proxy } from 'valtio'

export const carrito = proxy({
  lineas: [] as Array<{ id: string; cantidad: number }>,
})

export const acciones = {
  agregar(id: string) {
    const linea = carrito.lineas.find((l) => l.id === id)
    if (linea) linea.cantidad += 1
    else carrito.lineas.push({ id, cantidad: 1 })
  },
  vaciar() {
    carrito.lineas.length = 0
  },
}

Fíjate en carrito.lineas.length = 0 y en el push: mutas el array como mutarías cualquiera, y Valtio lo vuelve reactivo porque los arrays también se envuelven en el proxy. Esa cercanía entre lo que escribes y lo que piensas es justo lo que la inmutabilidad a mano encarecía.

useSnapshot: la instantánea que optimiza sola

En React no lees del proxy directamente: lees de una instantánea que produce useSnapshot. Esa instantánea es un objeto inmutable y congelado del estado en ese instante, pero lo verdaderamente astuto es cómo se comporta ante el render:

import { useSnapshot } from 'valtio'
import { estado } from './store'

function Contador() {
  const snap = useSnapshot(estado)   // instantanea inmutable con tracking de accesos
  return <button onClick={() => (estado.contador += 1)}>{snap.contador}</button>
}

useSnapshot no se limita a devolver el estado: envuelve la instantánea en otro proxy que registra qué propiedades lee el componente durante el render. Si este componente solo accedió a snap.contador, Valtio lo re-renderiza únicamente cuando contador cambia, y lo ignora por completo cuando cambia usuario.nombre. La optimización del render, que en otras librerías exiges declarando selectores, aquí se deduce sola de lo que el componente consumió de verdad.

flowchart LR
H[handler muta estado] --> P[proxy ES6 intercepta]
P -->|notifica cambio| N[nueva instantanea inmutable]
N -->|solo props leidas| R[re-render del componente]
style P fill:#f9e2af,color:#11111b
style N fill:#a6e3a1,color:#11111b
style R fill:#89b4fa,color:#11111b

Las instantáneas no se copian enteras en cada cambio: son estructuralmente compartidas. Solo las ramas del árbol que cambiaron obtienen referencias nuevas; el resto se reutiliza intacto. Por eso comparar dos instantáneas por identidad es fiable y barato, y por eso el motor de tracking —la librería proxy-compare que Valtio usa por dentro— puede decidir con precisión quirúrgica qué componentes deben actualizarse. Tienes lo mejor de los dos mundos: escribes mutando y el sistema razona con inmutabilidad.

Esta arquitectura explica por qué Valtio no necesita que declares selectores como Zustand: el selector eres tú, implícitamente, cada vez que lees una propiedad del snapshot en el render. El acceso ES la suscripción. La consecuencia es una API con menos conceptos —no hay que aprender qué es un selector ni cómo memoizarlo— a cambio de una regla de disciplina que hay que respetar sin ayuda del compilador, porque nada en el sistema de tipos te impide leer del proxy en lugar del snapshot y romper el tracking sin que nadie te avise.

La regla de oro y sus fronteras

Toda la corrección de Valtio se reduce a una disciplina de una línea: lees de la instantánea, escribes en el proxy. En el render y en el JSX consumes snap, la versión inmutable y rastreada; en los manejadores de eventos y la lógica mutas estado, la versión viva. Confundirlos es el origen de casi todos los bugs: mutar snap no hace nada porque está congelado, y leer estado directamente en el render rompe el tracking que optimiza los re-renders.

⚠️
Los tres errores clásicos

Primero, mutar la instantánea: snap.contador += 1 falla en silencio porque snap es inmutable; querías estado. Segundo, leer del proxy en el render: usar estado.contador en el JSX en vez de snap.contador te desengancha del tracking y el componente deja de actualizarse de forma fiable. Tercero, olvidar que el proxy es profundo pero no infinito: envuelve objetos y arrays anidados, pero Map, Set o instancias de clases necesitan cuidado —hay utilidades como proxyMap y proxySet para los dos primeros—. Interioriza la regla de oro y la inmensa mayoría de estos problemas ni aparecen.

Vale la pena entender por qué la regla es asimétrica: la instantánea es de solo lectura no por capricho, sino porque es el mecanismo de consistencia. Si pudieras mutar el snapshot, dos componentes verían versiones distintas del mismo estado según cuándo tomaron su instantánea, y la garantía de que todos leen un estado coherente se vendría abajo. La inmutabilidad del snapshot es precisamente lo que permite que el proxy mutable de fuera no contamine la coherencia de dentro.

Dicho de otro modo, Valtio traza una frontera nítida entre dos regiones: la del proxy, mutable y única, donde ocurre la verdad del estado; y la de las instantáneas, inmutables y efímeras, que son fotografías coherentes de esa verdad para renderizar. Cruzar esa frontera en el sentido correcto —escribir en la verdad, leer de la fotografía— es toda la disciplina que la librería te pide, y a cambio te libera de declarar dependencias.

Fuera de React, subscribe observa el proxy entero o una porción y ejecuta un efecto ante cada cambio, útil para sincronizar con almacenamiento o red sin re-render. Y derive, de valtio/utils, define valores computados que se recalculan cuando cambian sus fuentes, cubriendo el terreno derivado que en Jotai era el corazón del modelo:

import { subscribe } from 'valtio'
import { derive } from 'valtio/utils'

const derivado = derive({ doble: (get) => get(estado).contador * 2 })
subscribe(estado, () => console.log('cambio', estado.contador))
💡
ref para lo que no debe ser reactivo

No todo lo que metes en un proxy quiere ser observado. Instancias de clases pesadas, nodos del DOM o valores grandes que cambian sin que la UI deba reaccionar pueden envolverse con ref de valtio, que le dice al proxy no rastrees esto ni lo copies en las instantáneas. Es la válvula de escape para mezclar estado reactivo y referencias opacas en el mismo objeto sin pagar tracking por lo segundo, y evita copias costosas de datos que nunca se renderizan.

Dónde brilla el proxy mutable

Valtio no compite con Zustand por el estado global genérico; ocupa un nicho donde el modelo inmutable estorba. Para situarlo, conviene ver las tres filosofías de la ola ligera una junto a otra, porque no son rivales sino respuestas a formas distintas de problema:

🧊

Inmutable

Zustand y Redux: nunca mutas, sustituyes. Produces estado nuevo con set. Previsible y explícito, pero la actualización anidada cansa.

⚛️

Atómico

Jotai: el estado es un grafo de piezas componibles que se recalculan solas. Ideal para lo derivado e interdependiente.

🔁

Proxy mutable

Valtio: mutas por fuera, lees inmutable por dentro. La ergonomía imperativa con las garantías del snapshot.

El código imperativo por naturaleza —un bucle de juego que muta posiciones cada frame, un lienzo, una simulación física, un editor con miles de nodos— se expresa con violencia antinatural cuando cada cambio exige producir un objeto nuevo. Ahí mutar estado.entidades[i].x += velocidad es a la vez lo más legible y lo más directo, y Valtio lo vuelve reactivo sin que renuncies a esa naturalidad. También destaca en formularios profundamente anidados, donde la actualización inmutable de un campo hondo es tediosa y la mutación directa es trivial.

El reverso de esa potencia es saber cuándo NO usarlo. Para estado global simple y plano —tema, sesión, banderas— Valtio funciona pero no aporta sobre Zustand, y su capa de magia añade un coste de comprensión que no compensa. Para estado derivado e interdependiente, Jotai modela el grafo con más precisión. Valtio gana específicamente cuando la escritura es imperativa y anidada, no como default general; elegirlo para todo sería repetir, en pequeño, el mismo error de haber elegido Redux para todo.

Conviene también un apunte de rendimiento. El Proxy y la comparación de accesos tienen un coste, minúsculo en la práctica pero real, y en estructuras enormes con muchísimas mutaciones por segundo hay que medir en vez de asumir. En la abrumadora mayoría de los casos ese coste es despreciable frente a la claridad que compra; pero como toda abstracción que intercepta operaciones del lenguaje, no es literalmente gratis, y el ingeniero maduro lo sabe y lo verifica cuando el caso es extremo.

La reactividad no es un modelo, es una propiedad del acceso

Lo que Valtio demuestra —y por eso merece estudiarse aunque nunca lo uses— es que la reactividad fina no requiere que el programador declare sus dependencias; puede observarse. Redux, Zustand y hasta Jotai te piden en distinto grado que digas de qué depende cada cosa: un selector, una lista de get, una función de lectura. Todos parten de la premisa de que el sistema no puede saber qué te importa a menos que se lo declares. Valtio refuta esa premisa con una herramienta que el lenguaje regaló en ES6: el Proxy puede interceptar cada lectura y cada escritura, así que el sistema sí puede saber qué te importa, simplemente mirando a qué accediste. De ahí nace su doble cara aparentemente contradictoria: por fuera es un objeto mutable que escribes con la naturalidad más imperativa del mundo, estado.x = 1, y por dentro sirve instantáneas inmutables y congeladas, estructuralmente compartidas, de modo que solo las ramas que cambiaron obtienen referencias nuevas y el resto se reutiliza. Consigue a la vez la ergonomía que hizo odiar a Redux su ausencia y las garantías que hicieron a Redux imponerla. La lección profunda no es sobre estado sino sobre abstracción: durante años la comunidad asumió que la mutabilidad y la previsibilidad eran un trueque, que para tener render optimizado y trazable había que renunciar a escribir código natural. Valtio muestra que ese trueque era un artefacto de las herramientas, no una ley: con la intercepción adecuada, puedes mutar por fuera y razonar como inmutable por dentro. El precio, honesto, es que la magia tiene un coste de comprensión —cuando algo no reacciona, la causa suele ser una violación sutil de la regla de oro— y que el Proxy no llega gratis a todos los tipos ni a todos los motores antiguos. Pero como demostración de que las dicotomías del framework rara vez son dicotomías del problema, Valtio es de lo más instructivo que produjo la ola ligera, y su existencia obliga a preguntarte, ante cualquier herramienta, cuánto de su ceremonia era necesidad real y cuánto simple falta de imaginación sobre lo que el lenguaje ya permitía.

⚔️ Vuelve reactivo un objeto imperativo
  1. Crea un proxy con un objeto anidado —un usuario con dirección y preferencias— y muta un campo hondo por asignación directa. Comprueba que no necesitas esparcir ningún nivel.
  2. Consume el estado en dos componentes con useSnapshot, cada uno leyendo una propiedad distinta. Muta solo una y verifica en las devtools que solo se re-renderiza el componente que la consumía: el tracking de accesos en acción.
  3. Rompe la regla de oro a propósito: intenta mutar snap en un manejador y observa que no pasa nada; luego lee del proxy en el render y observa que el componente deja de actualizarse bien. Corrige ambos y explica con tus palabras por qué fallaba cada uno.
  4. Modela algo genuinamente imperativo —un contador de partículas que avanza en un intervalo— mutando el proxy en cada tick, y compara la naturalidad del código con lo que exigiría hacerlo inmutable en Zustand con Immer.
  5. Añade un valor computado con derive y una suscripción con subscribe que registre cada cambio fuera de React. Confirma que la suscripción corre sin provocar re-render de ningún componente.
  6. Envuelve con ref un valor que no deba ser reactivo —una instancia grande o un nodo del DOM— dentro del mismo proxy, y verifica que mutarlo no dispara re-renders ni aparece copiado en las instantáneas.