create: el store que es un hook
La función create de Zustand no es azúcar sobre la Context API: construye un store vanilla en el ámbito del módulo y le acopla encima un hook de React a través de useSyncExternalStore. Esta lección disecciona esa arquitectura de dos capas, explica por qué en TypeScript la llamada se escribe curvada, cómo set fusiona superficialmente y qué hace su segundo argumento de reemplazo, para qué sirve get dentro de una acción y por qué colocar datos y acciones en el mismo objeto produce referencias estables que ningún selector vuelve a invalidar. Cierra con la consecuencia que casi nadie anticipa: el store es un singleton de módulo, y esa propiedad, cómoda en el navegador, es una fuga en el servidor y un residuo entre tests.
Zustand se presenta con un eslogan que suena a marketing y resulta ser una descripción literal: el store es un hook. Pero esa frase esconde una arquitectura de dos capas que casi nadie disecciona. Debajo hay un store vanilla —un closure con un objeto de estado, un conjunto de suscriptores y tres métodos— que no sabe absolutamente nada de React. Encima, una función que convierte ese store en un hook mediante useSyncExternalStore. Entender esa separación no es curiosidad arqueológica: explica por qué no hace falta provider, por qué las acciones son estables entre renders, por qué el estado sobrevive al desmontaje de todos sus consumidores y por qué el store es un singleton de módulo que te morderá en el servidor si no lo ves venir. El nivel entero se construye sobre esta lección.
- Leer la firma real de
createy saber por qué en TypeScript se escribe curvada. - Dominar
set: fusión superficial, actualización funcional y el segundo argumento de reemplazo. - Usar
getpara leer el estado vigente dentro de una acción sin capturar closures obsoletos. - Reconocer el store como singleton de módulo y anticipar sus consecuencias en tests y en servidor.
Dos capas: un store vanilla y un hook encima
La librería que instalas contiene dos piezas separadas que create te sirve fusionadas. La primera es createStore, exportada desde zustand/vanilla: recibe un inicializador y devuelve un objeto con getState, setState, subscribe y getInitialState. Es un patrón observador clásico, de unas pocas decenas de líneas, sin ninguna dependencia de React. La segunda es useStore, exportada desde zustand/react: recibe ese store y un selector, y se conecta al árbol de React mediante useSyncExternalStore. La función create no hace más que llamar a la primera y devolver la segunda ya atada, con la API vanilla adherida como propiedades del propio hook.
flowchart LR A[create con inicializador] --> B[store vanilla getState setState subscribe] B --> C[useSyncExternalStore] C --> D[hook con selector en el componente] B --> E[codigo plano fuera de React] style B fill:#a6e3a1,color:#11111b style D fill:#89b4fa,color:#11111b style E fill:#f9e2af,color:#11111b
Esa doble salida es lo que hace a Zustand distinto de un contexto: el estado no vive en el árbol, vive en un módulo, y React es solo uno de sus consumidores posibles. El inicializador que le pasas recibe tres argumentos —set, get y la propia API del store— y devuelve el objeto que será el estado inicial, acciones incluidas.
import { create } from 'zustand'
type Linea = { sku: string; cantidad: number }
type Carrito = {
lineas: Linea[]
cupon: string | null
anadir: (l: Linea) => void
aplicarCupon: (codigo: string) => void
}
export const useCarrito = create<Carrito>()((set, get) => ({
lineas: [],
cupon: null,
anadir: (l) => set((s) => ({ lineas: [...s.lineas, l] })),
aplicarCupon: (codigo) => {
if (get().lineas.length === 0) return // lee el estado vigente, no uno capturado
set({ cupon: codigo })
},
}))
Habrás notado los paréntesis vacíos de create<Carrito>()(...). No son un tic de estilo: TypeScript no admite inferencia parcial de genéricos, así que si escribes create<Carrito>(fn) en un store con middleware, el compilador debe inferir a la vez el tipo del estado y la tupla de mutadores que los middleware inyectan, y falla. La forma curvada parte el problema en dos llamadas: en la primera fijas el estado a mano, en la segunda deja que se infieran los mutadores. Sin middleware, create<Carrito>(fn) funciona igual; con middleware, la forma curvada es obligatoria. Adóptala siempre y te ahorras recordar cuándo hace falta.
set: fusionar, reemplazar y no notificar de más
set acepta o bien un objeto parcial, o bien una función que recibe el estado previo y devuelve ese parcial. En ambos casos el resultado se fusiona superficialmente sobre el estado actual: las claves que no mencionas se conservan tal cual. Superficialmente significa exactamente eso, un solo nivel: si tu estado tiene un objeto anidado y devuelves una versión parcial de él, la clave entera se sustituye y pierdes los campos que no repetiste. Ese límite es la razón de existir del middleware immer, que veremos en la tercera lección.
// fusion superficial: cupon se conserva
set({ lineas: [] })
// segundo argumento true: reemplazo total del estado
set({ lineas: [], cupon: null, anadir, aplicarCupon }, true)
// anidamiento profundo sin immer: propagacion a mano
set((s) => ({ envio: { ...s.envio, pais: 'ES' } }))
El segundo argumento booleano fuerza el reemplazo en vez de la fusión, y es una escopeta cargada: si reemplazas el estado entero olvidando incluir las acciones, las borras del store y cualquier componente que las tuviera seleccionadas apuntará a undefined. Existe para reinicios deliberados y para tests, no para el uso diario.
Hay un detalle de rendimiento que conviene interiorizar ya. Cuando llamas a set, el store construye el siguiente estado y notifica a todos sus suscriptores; no compara campo a campo antes de avisar. Quien filtra es cada suscriptor con su selector, comparando su porción con Object.is. La consecuencia práctica es que un set que escribe el mismo valor que ya había no provoca ni un solo re-render, porque ningún selector detecta cambio, pero sí recorre la lista de suscriptores. Con cientos de componentes suscritos y actualizaciones a sesenta hercios, ese recorrido deja de ser gratis, y ahí es donde entran las suscripciones fuera de React de la última lección.
get y las acciones que viven dentro del estado
La decisión de diseño más subversiva de Zustand es que las acciones no son un artefacto aparte: son campos del propio estado, funciones almacenadas junto a los datos que modifican. Eso tiene dos consecuencias enormes. La primera es de legibilidad: el estado y sus transiciones se leen de un vistazo, sin saltar entre un archivo de tipos, uno de creadores y uno de reductores. La segunda es de rendimiento: como esas funciones se definen una sola vez, al crear el store, y nunca se vuelven a escribir con set, su referencia es estable para siempre. Un componente que solo selecciona una acción no se re-renderiza jamás.
get es la puerta a ese estado vigente desde dentro de una acción. Es importante entender por qué no basta con la forma funcional de set: set((s) => ...) te da el estado en el instante de la escritura, pero una acción asíncrona necesita leer el estado después de un await, cuando el mundo ya ha cambiado. Capturar el estado en una variable antes de esperar es la fuente clásica del closure obsoleto.
export const useCarrito = create<Carrito>()((set, get) => ({
lineas: [],
cupon: null,
anadir: (l) => set((s) => ({ lineas: [...s.lineas, l] })),
aplicarCupon: async (codigo) => {
const ok = await validarCupon(codigo)
if (!ok) return
if (get().lineas.length === 0) return // el carrito pudo vaciarse durante el await
set({ cupon: codigo })
},
}))
Existe un debate menor sobre dónde colocar las acciones, y conviene zanjarlo con criterio en vez de con gusto. Agruparlas bajo una clave actions ordena el objeto y permite seleccionarlas todas de golpe con un solo selector estable, pero rompe la fusión superficial en cuanto quieras modificar una sola y obliga a propagar el grupo entero. Dejarlas al mismo nivel que los datos, que es la recomendación oficial, mantiene la escritura trivial a cambio de un objeto más plano. La tercera vía —definir las acciones fuera del inicializador, como funciones del módulo que llaman a useCarrito.setState— separa datos de comportamiento y deja el estado serializable de arriba abajo, pero ata cada acción al store concreto que importa, así que se lleva mal con la fábrica por petición de la última lección.
getState
Lee el estado actual sin suscribirse ni renderizar nada. Es la vía para consultar el store desde un manejador de eventos, un interceptor de red o un test.
setState
Escribe desde fuera del inicializador, con la misma semántica de fusión superficial. Útil para reiniciar el store entre tests o para puentes con código heredado.
subscribe
Registra un oyente que corre en cada cambio y devuelve la función para darse de baja. Es la capa sobre la que React construye su suscripción.
getInitialState
Devuelve el estado con el que arrancó el store. La base de un reinicio honesto y del renderizado en servidor, donde el snapshot debe ser estable.
Singleton de módulo: la libertad y su factura
Que no haya provider significa que el store se crea cuando el módulo se evalúa por primera vez, y que a partir de ahí hay exactamente uno por proceso. En el navegador eso es puro beneficio: cualquier archivo que importe el hook accede al mismo estado sin que un ancestro tenga que proveerlo, no hay cascadas de re-render por cambio de valor de contexto, y el estado sobrevive al desmontaje de todos sus consumidores. Pero un singleton es un estado global con todo lo que la expresión implica, y la factura llega en dos escenarios concretos.
El primero son los tests: si el módulo se importa una vez y el test runner comparte el registro de módulos entre casos, el estado del primer test contamina al segundo. La solución idiomática es capturar el estado inicial y restaurarlo en un gancho de limpieza.
const inicial = useCarrito.getInitialState()
afterEach(() => {
useCarrito.setState(inicial, true) // reemplazo total: vuelve al punto cero
})
El segundo es el renderizado en servidor, donde el proceso de Node atiende muchas peticiones y todas comparten los módulos ya evaluados. Un store de módulo se convierte allí en una variable global compartida entre usuarios distintos, con el resultado evidente. La respuesta —crear el store por petición y proveerlo con un contexto— es la única situación en la que Zustand recupera el provider, y es el tema de la quinta lección de este nivel.
La ausencia de provider en Zustand se celebra como una comodidad, y esa lectura se queda a medio camino. Lo que en realidad hace create es deshacer una confusión que React arrastró durante una década: la de creer que el estado compartido debía vivir en el árbol de componentes porque el árbol era el único mecanismo de propagación disponible. Poner el estado en un contexto obligaba a que la posición del dato en la jerarquía determinase quién podía leerlo, y como cualquier cambio del valor del contexto invalida a todos sus consumidores sin distinción, el rendimiento pasó a depender de dónde habías colocado el provider, es decir, de una decisión topológica sin ninguna relación con el dominio. Toda la literatura sobre partir contextos, memoizar valores y elevar el estado fue el esfuerzo colectivo por compensar esa confusión de origen. Zustand la corta de raíz al reconocer que un store es un objeto observable que existe con independencia de quién lo mire, y que React solo necesita un puente para enterarse de sus cambios; ese puente, useSyncExternalStore, lo estandarizó la propia React en la versión 18, lo que convierte a Zustand menos en una alternativa a React y más en el uso canónico de una API que React construyó precisamente para esto. La lección honda es que la suscripción es un asunto del consumidor y no del proveedor: cuando cada componente declara con un selector qué porción le importa, el rendimiento deja de depender de la forma del árbol y pasa a depender de lo que cada uno pide, que es la única variable con significado real. El precio de esa claridad es el singleton, y no es un precio menor: has cambiado un estado con ciclo de vida atado al montaje por uno con ciclo de vida atado al módulo, y todo lo que el árbol te limpiaba gratis —al desmontar, al cambiar de usuario, al terminar un test, al servir otra petición— pasa a ser responsabilidad tuya. Zustand no elimina el problema del estado global: lo hace explícito, que es exactamente lo que un buen diseño debe hacer con los problemas que no puede resolver.
- Escribe un store con
createy otro equivalente concreateStoredezustand/vanillamásuseStoredezustand/react. Comprueba que se comportan idénticamente y explica en una frase qué añadecreate. - Provoca el fallo de la fusión superficial: guarda un objeto anidado y actualiza solo una de sus claves devolviendo un parcial sin propagar. Observa qué campos desaparecen y arréglalo con propagación explícita.
- Usa
setcon el segundo argumento entrueolvidando incluir las acciones. Verifica que los componentes que las seleccionaban recibenundefinedy razona por qué ese reemplazo es tan peligroso. - Escribe una acción asíncrona que lea el estado antes de un
awaiten una variable local y compárala con la que lo lee congetdespués. Fuerza un cambio durante la espera y comprueba cuál de las dos toma la decisión correcta. - Añade un gancho de limpieza en tus tests que restaure
getInitialStatecon reemplazo total. Escribe dos tests que se contaminen sin él y confirma que con él pasan en cualquier orden. - Selecciona en un componente únicamente una acción y registra sus renders. Comprueba que no vuelve a renderizarse ni una vez pese a que los datos del store cambian constantemente.