wandres.dev
ZUSTAND A FONDO · el store minimalista

Fuera de React: código plano, transitorio y SSR

El hook es solo una de las dos caras del store. La otra es la API vanilla, y con ella se lee y se escribe desde un interceptor de red, un manejador de eventos, un worker o un test, sin montar un solo componente. Esta lección desarrolla getState, setState y subscribe, las actualizaciones transitorias que reaccionan a sesenta hercios sin renderizar, y por qué getInitialState existe para servir el snapshot del servidor. Después aborda el problema serio: un store en el ámbito del módulo es una variable compartida entre peticiones de usuarios distintos, y la única solución correcta es la fábrica por petición provista con contexto, el único caso en el que Zustand recupera el provider que presumía de no necesitar.

⏱ 19 min

Toda la lección primera insistía en que create devuelve dos cosas: un hook y, adherida a él, la API completa del store vanilla. Hasta ahora hemos vivido en la primera cara. La segunda es la que convierte a Zustand en algo más que una librería de React: un store al que se puede leer y escribir desde un interceptor de fetch, un manejador de teclado global, un worker o un test, sin que exista un árbol de componentes. Y es también la cara que esconde el único defecto estructural de la librería, el que ninguna cantidad de selectores arregla: si el store vive en el ámbito del módulo y el módulo vive en un proceso de Node que atiende a mil usuarios, el store es de los mil. Esta lección cubre las dos mitades, la potencia y la fuga.

🎯 Al terminar esta lección sabrás
  • Leer y escribir el store desde código plano con getState, setState y subscribe.
  • Implementar actualizaciones transitorias que reaccionan sin provocar ni un render.
  • Explicar por qué existe getInitialState y qué papel juega en el renderizado de servidor.
  • Construir un store por petición con fábrica y contexto para no filtrar estado entre usuarios.

El store sin React

Los tres métodos de la API vanilla cubren el ciclo completo y no requieren nada del entorno. getState devuelve el estado vigente sin suscribir a nadie; setState escribe con la misma semántica de fusión superficial que el set interno; subscribe registra un oyente y devuelve la función para darlo de baja. Con eso basta para integrar el store con cualquier cosa.

// un interceptor de red que lee la sesion sin ser un componente
export async function apiFetch(url: string, init?: RequestInit) {
  const { token, salir } = useSesion.getState()
  const res = await fetch(url, {
    ...init,
    headers: { ...init?.headers, Authorization: `Bearer ${token}` },
  })
  if (res.status === 401) salir()   // escribir el store desde codigo plano
  return res
}

Este patrón resuelve de un plumazo la clase entera de problemas que en Redux exigía inyectar el store en el módulo de la API o pasarlo por parámetro. Pero conviene enunciar la regla que lo mantiene sano: getState es correcto en código que se ejecuta por un evento, y es un error dentro del cuerpo de un componente. En pleno render, leer con getState no crea suscripción, así que el componente mostrará un valor que puede quedar obsoleto sin que nada lo despierte, y además rompe las garantías del renderizado concurrente. Dentro de React, el selector; fuera de React, getState.

🌐

Interceptores y clientes HTTP

Leer el token, cerrar la sesión ante un 401, adjuntar el idioma activo. Lógica de red que necesita el estado sin pertenecer a la interfaz.

⌨️

Manejadores globales

Atajos de teclado, eventos de la ventana, mensajes de otra pestaña. Se registran una vez y escriben en el store como lo haría un componente.

🧪

Tests sin montar nada

Ejercitar acciones con getState y afirmar sobre el resultado. Un store es lógica pura y probarla no debería requerir un renderizador.

🎞️

Bucles de animación

Canvas, WebGL, arrastre. Valores que cambian decenas de veces por segundo y no deben pasar por el reconciliador.

Actualizaciones transitorias: reaccionar sin renderizar

Hay una categoría de estado que rompe el modelo de render por cambio: la posición del ratón, el desplazamiento de la página, el progreso de una animación. Suscribir un componente a esos valores significa reconciliar a la frecuencia de refresco de la pantalla, y ninguna optimización de selector lo salva porque el valor sí cambia de verdad. La solución idiomática se llama actualización transitoria: suscribirse fuera del ciclo de render y aplicar el efecto a mano.

function Cursor() {
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    // subscribeWithSelector permite escuchar solo una porcion
    return usePuntero.subscribe(
      (s) => s.x,
      (x) => { if (ref.current) ref.current.style.transform = `translateX(${x}px)` },
      { fireImmediately: true },
    )
  }, [])

  return <div ref={ref} className="cursor" />
}

Repara en la geometría del truco: la suscripción se registra en un efecto, se limpia devolviendo la propia función de baja, y el valor viaja del store al DOM sin pasar por el estado de React ni por sus propiedades. El componente sigue siendo el dueño del nodo —lo creó y lo destruirá— pero delega la actualización de un atributo concreto a un canal paralelo. Es exactamente el mismo compromiso que hacen las librerías de animación al escribir estilos fuera del reconciliador, con la diferencia de que aquí la fuente del valor es tu propio store y no un motor opaco.

El componente se renderiza una sola vez en toda su vida. El store sigue actualizándose a la frecuencia que haga falta, pero React nunca se entera, porque el puente entre el dato y el DOM es una escritura directa sobre un nodo que ya existe.

💡
El oyente recibe el valor anterior, y eso habilita otro patrón

Con subscribeWithSelector, la firma del oyente es de dos argumentos: el valor nuevo y el previo. Eso permite reaccionar a transiciones y no solo a estados, que es una capacidad que el hook no tiene. Detectar que la sesión pasó de tener usuario a no tenerlo, y solo entonces limpiar cachés o redirigir, es una condición sobre la arista y no sobre el nodo. La opción fireImmediately invoca el oyente en el momento de suscribirse con el valor actual, lo que evita el clásico hueco entre montar y el primer cambio.

El singleton es una fuga entre peticiones

En el navegador, un store por módulo es un store por usuario, porque cada pestaña tiene su propio entorno. En el servidor, la aritmética es otra: el módulo se evalúa una vez por proceso y el proceso atiende peticiones de gente distinta. Un store creado con create en el ámbito del módulo es, en ese contexto, una variable global compartida entre usuarios. Si una petición escribe el nombre de quien inició sesión, la siguiente lo lee.

flowchart TD
A[proceso de Node con el modulo ya evaluado] --> B[store unico creado al importar]
B --> C[peticion de la usuaria A escribe su sesion]
B --> D[peticion del usuario B lee la sesion de A]
A --> E[alternativa: fabrica por peticion]
E --> F[store nuevo dentro de un contexto de React]
style D fill:#f38ba8,color:#11111b
style F fill:#a6e3a1,color:#11111b

Hay un segundo problema, menos grave pero más ruidoso, y es el snapshot. useSyncExternalStore exige una función que devuelva el estado en el servidor, y debe ser estable: si en cada llamada devolviera algo distinto, React no podría comparar. Por eso existe getInitialState, que Zustand usa como snapshot de servidor mientras getState sirve al cliente. La consecuencia práctica es que el HTML se genera siempre con el estado inicial del código, así que cualquier estado que solo el cliente conoce —lo rehidratado por persist, lo leído de localStorage— producirá un desajuste de hidratación si lo pintas en el primer render.

Store por petición: fábrica y contexto

La solución correcta es dejar de crear el store al importar el módulo y crearlo en una fábrica que se invoca una vez por petición en el servidor y una vez por vida de la aplicación en el cliente. Como ya no hay un singleton al que importar, hace falta un canal para repartirlo, y ese canal es un contexto de React. Es el único uso legítimo de un provider en Zustand, y merece subrayarse que el contexto transporta la referencia al store, que nunca cambia, y no el estado: por eso no reaparece el problema de rendimiento del contexto clásico, y la suscripción sigue siendo por selector.

'use client'
import { createContext, useContext, useRef, type ReactNode } from 'react'
import { createStore } from 'zustand/vanilla'
import { useStore } from 'zustand'

type TiendaStore = ReturnType<typeof crearTiendaStore>

export const crearTiendaStore = (inicial?: Partial<Tienda>) =>
  createStore<Tienda>()((set) => ({ ...estadoBase, ...inicial, /* acciones */ }))

const Ctx = createContext<TiendaStore | null>(null)

export function TiendaProvider({ children, inicial }: { children: ReactNode; inicial?: Partial<Tienda> }) {
  const ref = useRef<TiendaStore>()
  if (!ref.current) ref.current = crearTiendaStore(inicial)   // una sola vez por arbol
  return <Ctx.Provider value={ref.current}>{children}</Ctx.Provider>
}

export function useTienda<T>(sel: (s: Tienda) => T): T {
  const store = useContext(Ctx)
  if (!store) throw new Error('useTienda fuera de TiendaProvider')
  return useStore(store, sel)
}

Con persist en la pila, la fábrica por petición exige un cuidado extra: en el servidor no existe localStorage, así que hay que activar skipHydration y rehidratar en el cliente dentro de un efecto, o dar un almacén de no operación cuando window no está definido. Y si varias pestañas comparten la misma clave de persistencia, el estado sembrado por el servidor y el guardado en el navegador compiten; decidir cuál gana es una decisión de producto, no de infraestructura, y conviene escribirla en el merge de persist en lugar de dejarla al azar del orden de ejecución.

Los dos detalles que suelen fallar están en el proveedor. El primero es crear el store con useState o directamente en el cuerpo del componente: en modo estricto y con render concurrente, eso puede construirlo dos veces, y la guarda sobre una referencia lo evita sin efectos. El segundo es el parámetro inicial, que es la vía por la que el servidor siembra el store con datos que ya conoce —la sesión, la configuración regional— y que el cliente hereda sin volver a pedirlos.

La ausencia de provider era una optimización de un caso, no un principio

Conviene mirar de frente lo que acaba de ocurrir en esta lección: la característica más publicitada de Zustand, no necesitas un provider, resulta ser válida únicamente bajo una hipótesis que la propia industria dejó de cumplir. Esa hipótesis es que el proceso donde vive el módulo pertenece a un solo usuario, algo trivialmente cierto en una aplicación de una sola página del año 2019 y directamente falso en cuanto una parte del renderizado sucede en un servidor compartido. La ausencia de provider nunca fue un principio arquitectónico, fue el aprovechamiento inteligente de una circunstancia; y lo interesante no es que la circunstancia haya cambiado, sino que el modelo de Zustand sobrevive intacto a ese cambio pagando exactamente lo que costaba y ni un céntimo más. Cuando el singleton deja de ser aceptable, se reintroduce un contexto, pero un contexto que transporta una referencia inmutable y no un valor, con lo cual el mecanismo de suscripción sigue siendo el selector y no se recupera ninguno de los problemas de rendimiento por los que se huyó del contexto en primer lugar. Eso solo es posible porque la librería mantuvo desde el principio la separación entre el store, que es un observable puro sin ninguna relación con el árbol, y el puente hacia React, que es una función de tres líneas sobre useSyncExternalStore. La lección general, que vale mucho más allá de la gestión de estado, es que la robustez de un diseño no se mide por cuántas comodidades ofrece en su caso feliz sino por cuánto hay que desmontar cuando ese caso deja de darse: una librería que hubiera fundido el store con el ciclo de vida de React para regalar la misma comodidad habría necesitado una reescritura para atender al servidor, mientras que aquí basta con cambiar dónde se llama a la fábrica. Y hay un corolario incómodo para el lector práctico: si tu aplicación renderiza en servidor, la fábrica por petición no es una optimización avanzada que puedas dejar para más adelante, es la forma correcta desde la primera línea, porque el fallo que evita no se manifiesta como un error sino como los datos de una persona apareciendo en la pantalla de otra.

⚔️ Saca el store de React y devuélvelo al servidor sin fugas
  1. Mueve la lectura del token de autenticación desde un componente a un interceptor de red que use getState. Comprueba que la petición sigue llevando el token con cero componentes montados.
  2. Escribe un test que ejercite tres acciones encadenadas usando solo getState y setState, sin renderizador. Mide cuánto tarda comparado con el mismo test montando componentes.
  3. Implementa un valor de alta frecuencia —la posición del ratón— primero con un selector y luego con una actualización transitoria. Cuenta renders en ambos casos con las herramientas de React.
  4. Usa el segundo argumento del oyente de subscribeWithSelector para detectar la transición de sesión iniciada a sesión cerrada y dispara una limpieza solo en esa arista.
  5. Reproduce la fuga: en una aplicación con renderizado en servidor, escribe en un store de módulo durante una petición y léelo en otra. Confirma el contagio antes de arreglarlo.
  6. Convierte ese store a fábrica con contexto, siembra el estado inicial desde el servidor y verifica que la fuga desaparece y que no se recupera ningún re-render de más por el contexto.