useSnapshot: la foto inmutable y su seguimiento
La mitad de escritura de Valtio es una asignación; la mitad de lectura es useSnapshot, y ahí vive la parte más astuta del diseño. Esta lección desarma la instantánea: por qué está congelada, cómo se comparte estructuralmente entre versiones, qué hace el segundo proxy que envuelve la foto para registrar qué propiedades tocó el render, cómo se decide con ese registro si un componente debe volver a renderizarse, y qué patrones rompen el seguimiento sin que nadie te avise. La regla de oro deja de ser un mandamiento y pasa a ser una consecuencia.
Si la lección anterior explicó cómo Valtio sabe que algo cambió, esta explica cómo decide a quién le importa. Y la respuesta es más elegante de lo que sugiere la API: useSnapshot no devuelve el estado, devuelve una fotografía congelada envuelta en un segundo proxy cuya única misión es apuntar qué propiedades leyó tu componente mientras se renderizaba. Ese cuaderno de accesos es el selector que nunca escribiste. Cuando llega un cambio, la librería contrasta lo que cambió con lo que ese componente leyó de verdad y decide en consecuencia. Entender este segundo proxy —distinto del de escritura, con un propósito opuesto— es lo que transforma la regla lee del snapshot, escribe en el proxy de superstición en teorema.
- Describir qué es exactamente una instantánea y por qué está congelada y compartida estructuralmente.
- Explicar el segundo proxy de seguimiento que registra los accesos durante el render.
- Predecir con precisión qué componentes se re-renderizan ante una mutación concreta.
- Reconocer los patrones que rompen el seguimiento y saber corregirlos sin apagar la optimización.
Qué es una instantánea
Una instantánea es el estado del proxy en un instante, convertido en una estructura inmutable y congelada. No es una copia profunda: las ramas que no cambiaron desde la foto anterior se reutilizan tal cual, con la misma identidad, y solo las que cambiaron reciben objetos nuevos. Esa compartición estructural es lo que hace viable tomar fotos continuamente sin arruinar el rendimiento, y también lo que hace fiable comparar dos fotos por referencia.
import { proxy, snapshot } from 'valtio'
const estado = proxy({ perfil: { nombre: 'Ada' }, contador: 0 })
const a = snapshot(estado)
estado.contador += 1
const b = snapshot(estado)
a === b // false: la raiz cambio
a.perfil === b.perfil // true: esa rama no fue tocada
Ese par de comparaciones concentra toda la idea. La raíz cambió porque el contador vive en ella, pero perfil conservó su identidad porque nadie lo tocó, y por tanto cualquier consumidor que solo mire perfil puede concluir en una comparación de referencias que no tiene nada que hacer. Es exactamente la propiedad que la ortodoxia inmutable perseguía con reductores escritos a mano, obtenida aquí como subproducto del motor de versiones.
La instantánea está congelada de verdad, no por convención. Intentar mutarla no hace nada en modo laxo y lanza en modo estricto, y esa dureza es deliberada: si dos componentes pudieran alterar sus respectivas fotos, dejarían de ser fotografías del mismo estado y la coherencia entre vistas se perdería. La inmutabilidad del snapshot no es una precaución estilística, es el mecanismo que garantiza que todos los consumidores de un mismo instante vean lo mismo.
La función snapshot existe con independencia de React y es la herramienta correcta para lo que no es render: registrar el estado en un log, serializarlo para persistirlo, enviarlo por la red o compararlo en una prueba. useSnapshot es esa misma función más la suscripción y el seguimiento que el ciclo de vida de un componente necesita. Separarlas conceptualmente ayuda: la foto es un concepto del modelo, el hook es su adaptador a la vista.
El cuaderno de accesos
Aquí aparece el segundo proxy. useSnapshot toma la instantánea congelada y la envuelve en un proxy de solo lectura cuya trampa de acceso anota cada propiedad que el componente consulta durante el render. Al terminar el render, la librería tiene una lista precisa de lo que ese componente miró: no lo que declaró mirar, sino lo que miró.
import { useSnapshot } from 'valtio'
import { estado } from './store'
function Nombre() {
const snap = useSnapshot(estado)
return <span>{snap.perfil.nombre}</span>
}
function Contador() {
const snap = useSnapshot(estado)
return <button onClick={() => (estado.contador += 1)}>{snap.contador}</button>
}
Al pulsar el botón, Contador se re-renderiza y Nombre no. No porque alguien lo haya declarado, sino porque el cuaderno de Nombre contiene solo la ruta hacia perfil.nombre, y esa rama conservó su identidad en la nueva foto. El seguimiento es de rutas, no de objetos completos: acceder a snap.perfil.nombre registra el camino, de modo que cambiar perfil.apellido tampoco despierta a Nombre, aunque perfil sea el mismo objeto padre.
flowchart LR
P[proxy mutable] -->|mutacion agrupada| S[instantanea congelada]
S --> T[proxy de seguimiento]
T -->|registra rutas leidas| C[cuaderno de accesos]
M[cambio nuevo] --> D{alguna ruta leida cambio?}
C --> D
D -->|si| R[re-render]
D -->|no| N[nada que hacer]
style T fill:#f9e2af,color:#11111b
style C fill:#94e2d5,color:#11111b
style R fill:#89b4fa,color:#11111b
style N fill:#a6e3a1,color:#11111bConviene ver el contraste con la alternativa explícita para calibrar qué se gana. En Zustand el equivalente exige nombrar la dependencia y, si el selector devuelve un objeto nuevo en cada llamada, añadir una función de igualdad para que la comparación no falle siempre:
// explicito: la dependencia es codigo que alguien escribio y puede olvidar
const nombre = useStore((s) => s.perfil.nombre)
// observado: la dependencia es un hecho del render, no una declaracion
const snap = useSnapshot(estado)
const otroNombre = snap.perfil.nombre
La versión observada no puede desincronizarse de lo que el componente usa, porque es literalmente lo que el componente usó. La versión explícita puede quedarse obsoleta cuando alguien cambia el cuerpo del render y olvida el selector, que es uno de los errores más frecuentes y más silenciosos del oficio. A cambio, la explícita se lee en una revisión de código y la observada no.
El módulo que hace este trabajo, proxy-compare, se publica aparte y merece atención propia: compara dos objetos guiado por un registro de accesos, en vez de comparar todo con todo. Esa inversión es la clave del rendimiento, porque el coste de decidir si un componente debe actualizarse deja de depender del tamaño del estado y pasa a depender del tamaño de lo que ese componente leyó, que casi siempre es diminuto. Un estado con diez mil nodos y un componente que lee tres campos cuesta lo mismo que un estado con diez nodos.
La frase que resume el modelo es que en Valtio el acceso ES la suscripción. Donde Zustand te pide un selector explícito y Jotai una lista de átomos leídos, aquí basta con usar el dato: consumirlo en el render es declararlo como dependencia. La consecuencia práctica es que la optimización no se olvida nunca, porque no hay nada que recordar hacer. Y la contrapartida honesta es que tampoco se puede inspeccionar de un vistazo: la dependencia no está escrita en ningún sitio, hay que deducirla leyendo el cuerpo del componente.
Los cuatro modos de romper el seguimiento
El seguimiento es automático, y por eso mismo es silencioso cuando se rompe. No hay error, no hay aviso: simplemente el componente se actualiza más de lo necesario o deja de actualizarse. Los modos de fallo son pocos y reconocibles.
Leer del proxy en el render
Usar estado.contador en vez de snap.contador dentro del cuerpo del componente saltea el cuaderno y desengancha la actualizacion.
Mutar la foto
Asignar sobre snap no hace nada porque esta congelada. La escritura siempre va al proxy vivo, nunca a la instantanea.
Desestructurar demasiado pronto
Extraer todo el objeto de golpe registra el nodo entero como leido y convierte una dependencia fina en una gruesa.
Leer dentro de una condicion
Si una rama del render lee un campo y otra no, el cuaderno cambia entre renders y la optimizacion se vuelve dificil de predecir.
El tercer caso es el más sutil porque parece inocente. Escribir una desestructuración amplia de la instantánea al principio del componente hace que el proxy de seguimiento registre todas esas propiedades, aunque el render acabe usando una sola por culpa de una condición. El seguimiento es fiel a lo que ocurrió, no a lo que se usó al final, así que la disciplina consiste en tocar la foto lo más tarde y lo más fino posible.
El cuarto caso invita a un matiz importante sobre la naturaleza del cuaderno: se reconstruye en cada render. Si un componente lee un campo solo cuando una bandera está activa, mientras la bandera esté apagada ese campo no figura en el cuaderno y sus cambios no despiertan al componente. Eso es correcto —no lo estaba usando— pero puede parecer un bug si esperabas una suscripción estática. Pensar en el cuaderno como algo vivo, que refleja el último render y no un contrato permanente, disuelve la confusión.
Leer snap dentro de un manejador de eventos o de un efecto no es un error de corrección, pero sí una fuente de datos rancios: la foto que capturaste pertenece al render en que se creó. Para lógica que necesita el valor actual en el momento de ejecutarse, lee del proxy vivo, que siempre está al día. La regla completa, entonces, tiene dos mitades simétricas: en el render lee de la foto para ganar seguimiento; fuera del render lee del proxy para ganar actualidad.
La regla de oro como teorema
Con el mecanismo delante, la regla de oro deja de ser una norma arbitraria. Lee de la instantánea porque solo la instantánea pasa por el proxy de seguimiento y solo así el sistema sabe qué te importa. Escribe en el proxy porque solo el proxy tiene trampas de escritura y solo así el sistema se entera de que algo cambió. Cada mitad de la regla es la consecuencia directa de qué mecanismo vive en cada objeto, y por eso quien entiende los dos proxies no necesita memorizar nada: deduce la regla cada vez que la necesita.
Hay además una consecuencia sobre el paso de propiedades entre componentes que conviene anticipar. Si un componente padre lee la instantánea y pasa un trozo a un hijo como propiedad, la dependencia queda registrada en el padre y no en el hijo, de modo que cualquier cambio en ese trozo re-renderiza a los dos. Cuando el hijo es caro, la solución idiomática es que sea él quien llame a useSnapshot sobre el mismo proxy y lea lo que necesita, en vez de recibirlo. El patrón invierte el consejo habitual de elevar el estado: aquí conviene bajar la lectura hasta donde se usa, porque la lectura es la suscripción y suscribirse arriba suscribe a todo el subárbol.
// menos fino: la dependencia vive en el padre y arrastra al hijo
function Padre() {
const snap = useSnapshot(estado)
return <Hijo nombre={snap.perfil.nombre} />
}
// mas fino: cada componente registra solo lo que mira
function Hijo() {
const snap = useSnapshot(estado)
return <span>{snap.perfil.nombre}</span>
}
Queda una pregunta natural que conviene responder para cerrar el modelo. Si el seguimiento es tan bueno, por qué existen librerías que exigen selectores explícitos. Porque la explicitud tiene virtudes que la observación no da: un selector es una función nombrada, testeable, reutilizable y visible en el código, mientras que un cuaderno de accesos es un hecho de tiempo de ejecución que nadie puede leer en una revisión de código. Ambos enfoques compran cosas distintas, y la lección quinta de este nivel pone esa comparación por escrito con criterios honestos.
Es tentador leer useSnapshot como un detalle de implementación, un adaptador para que React, que exige inmutabilidad, pueda consumir un estado que no lo es. Esa lectura funciona pero se queda corta y oculta lo interesante. Lo que la instantánea hace de verdad es trazar una frontera entre dos regímenes de verdad con reglas incompatibles y hacerlos convivir sin que ninguno renuncie a lo suyo. Del lado del modelo, el estado es una entidad viva, única, que evoluciona en el tiempo y que se manipula con la naturalidad imperativa con que pensamos las acciones: incrementa esto, añade aquello, quita lo otro. Del lado de la vista, el estado debe ser un valor fijo y coherente durante todo un render, porque una interfaz que se calcula a partir de datos que cambian a mitad del cálculo es una interfaz que puede mostrar un estado que nunca existió. La instantánea reconcilia ambos regímenes con un movimiento que la programación funcional conoce desde hace décadas y que las bases de datos llaman aislamiento por instantánea: no se le prohíbe cambiar al mundo, se le da a cada lector una versión estable del instante en que empezó a leer. Esa es la misma idea que hace posible el control de concurrencia multiversión en un motor transaccional y la que sostiene la reconciliación concurrente de React, y encontrarla aquí, en tres kilobytes de librería de frontend, debería recalibrar tu sentido de qué ideas son grandes y dónde viven. La lección transferible excede a Valtio: cuando dos partes de un sistema exigen garantías contradictorias, la salida rara vez es obligar a una a ceder, y casi siempre es encontrar la frontera donde una versión estable de la verdad puede entregarse a quien la necesita fija mientras el otro lado sigue avanzando. Diseñar esa frontera es diseño de sistemas, no ergonomía de API, y quien lo entiende empieza a ver el mismo patrón en la cache del navegador, en el aislamiento de una transacción y en cada render de su aplicación.
- Monta dos componentes que lean campos distintos del mismo proxy y confirma con el perfilador que una mutación solo despierta a uno. Anota la ruta exacta que cada uno registró.
- Cambia uno de ellos para que desestructure el objeto padre entero y repite la medición. Explica el aumento de renders en términos del cuaderno de accesos.
- Sustituye una lectura de
snappor una lectura del proxy vivo en el cuerpo del render y observa cómo el componente deja de actualizarse de forma fiable. Revierte y razona el porqué. - Escribe un componente que lea un campo solo cuando una bandera está activa. Muta ese campo con la bandera apagada, comprueba que no hay render y decide si ese comportamiento es correcto para tu caso.
- Toma dos instantáneas con
snapshotalrededor de una mutación y compara por identidad la rama tocada y una intacta. Usa el resultado para explicar la compartición estructural con tus palabras. - Lee
snapdentro de un manejador de eventos, provoca un cambio antes de que el manejador corra y observa el dato rancio. Corrígelo leyendo del proxy y formula la regla completa en dos frases.