Middleware: persist, immer, devtools y el orden correcto
Un middleware de Zustand es una función que envuelve el inicializador y puede sustituir set, get o la API del store antes de devolverlos. Esta lección explica esa firma, por qué el orden de composición no es intercambiable y cuál es la pila recomendada, y luego entra en cada pieza con el detalle que la documentación resume: persist con partialize, version, migrate, createJSONStorage y el ciclo de hidratación; immer y el borrador que convierte la fusión superficial en escritura profunda; devtools y el tercer argumento de set que da nombre a cada acción; y subscribeWithSelector, que amplía subscribe con selector, comparador e invocación inmediata. Cierra con el coste real que la composición impone al sistema de tipos.
El núcleo de Zustand cabe en unos pocos cientos de líneas porque casi todo lo interesante vive fuera de él, en middleware que no pagas hasta que los pides. Pero esa modularidad tiene una letra pequeña que la mayoría descubre depurando: los middleware no son adornos independientes que se apilan en cualquier orden, son transformaciones que reescriben set, get y la API del store antes de pasárselos al siguiente, y por tanto componen como funciones, no como configuración. Quién envuelve a quién determina qué ve cada uno: si devtools queda por dentro de persist, no verá la rehidratación; si immer queda por fuera, el borrador llegará a quien no sabe qué hacer con él. Esta lección explica la mecánica para que el orden deje de ser superstición copiada de un ejemplo.
- Leer la firma de un middleware y entender qué reescribe de
set,gety la API del store. - Componer la pila en el orden correcto y justificar cada anidamiento.
- Configurar
persistconpartialize,version,migratey el ciclo de hidratación. - Aprovechar
immer,devtoolsysubscribeWithSelectorsin romper el tipado del store.
Qué es realmente un middleware
Un middleware es una función que recibe el inicializador del store y devuelve otro inicializador. Cuando create ejecute el resultado, ese envoltorio recibirá los set, get y api originales, podrá sustituirlos por versiones propias y llamará al inicializador interno con los suyos. immer reemplaza set por uno que acepta una función mutadora; devtools reemplaza set por uno que además emite un evento a la extensión; persist no toca set pero se suscribe al store y añade métodos a api; subscribeWithSelector sustituye api.subscribe por una versión con más parámetros.
// la forma esencial: envolver el inicializador y decidir que set recibe el de dentro
const registrar = (f) => (set, get, api) =>
f(
(...args) => {
console.log('antes', get())
set(...args)
console.log('despues', get())
},
get,
api,
)
export const useCarrito = create<Carrito>()(registrar((set) => ({ /* ... */ })))
flowchart TD A[devtools capa externa] --> B[persist capa intermedia] B --> C[immer capa interna] C --> D[inicializador con estado y acciones] D -->|set mutador| C C -->|set inmutable| B B -->|escribe en el almacen| A style A fill:#89b4fa,color:#11111b style C fill:#a6e3a1,color:#11111b
De ahí sale la regla de composición. immer va lo más adentro posible, porque su trabajo es traducir la escritura del inicializador; todo lo que quede por fuera debe recibir ya un objeto normal. devtools va lo más afuera posible, porque su trabajo es observar, y quiere ver también los cambios que originan los demás, incluida la rehidratación de persist. La pila canónica es por tanto devtools envolviendo a persist envolviendo a immer.
import { create } from 'zustand'
import { devtools, persist, createJSONStorage } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
export const useCarrito = create<Carrito>()(
devtools(
persist(
immer((set) => ({
lineas: [],
anadir: (l) => set((s) => { s.lineas.push(l) }), // borrador de immer
})),
{ name: 'carrito', storage: createJSONStorage(() => localStorage) },
),
{ name: 'carrito', enabled: import.meta.env.DEV },
),
)
persist: no es guardar, es un ciclo de vida
persist parece un interruptor y es en realidad una máquina de estados con cuatro decisiones que hay que tomar a conciencia. La primera es qué se guarda: por defecto, el estado entero, funciones incluidas, que al serializarse a JSON simplemente desaparecen. Casi siempre quieres partialize para elegir explícitamente las claves persistibles y dejar fuera lo efímero y lo derivado. La segunda es dónde: storage acepta cualquier almacén con la forma esperada, envuelto en createJSONStorage, lo que permite usar sessionStorage, IndexedDB o un almacén asíncrono en móvil. La tercera es cómo evoluciona: version más migrate convierten los datos viejos guardados en el navegador de un usuario que no ha vuelto en seis meses, y sin ellos un cambio de forma del estado provoca una rehidratación silenciosamente corrupta. La cuarta es cuándo: la hidratación es asíncrona en general, y hay una ventana en la que el store tiene el estado inicial y no el guardado.
persist(inicializador, {
name: 'carrito',
version: 2,
partialize: (s) => ({ lineas: s.lineas, cupon: s.cupon }), // nada de funciones
migrate: (guardado, version) => {
if (version < 2) return { ...guardado, cupon: null } // la clave es nueva
return guardado
},
onRehydrateStorage: () => (estado, error) => {
if (error) console.error('rehidratacion fallida', error)
},
})
Entre el primer render y el final de la rehidratación, el store contiene el estado inicial del código, no el guardado. En una aplicación puramente de cliente eso produce un parpadeo; con renderizado en servidor produce algo peor: el HTML del servidor se genera con el estado inicial, el cliente hidrata con el estado guardado y React detecta que el árbol no coincide. La solución no es suprimir el aviso sino reconocer el problema como lo que es —un dato que el servidor no podía conocer— y retrasar su uso: skipHydration en true y una llamada manual a useCarrito.persist.rehydrate() dentro de un efecto, o una bandera de hidratación completada que decida qué se pinta. useCarrito.persist.hasHydrated() y onFinishHydration existen exactamente para esto.
immer, devtools y subscribeWithSelector
immer
Sustituye set por uno que recibe un borrador mutable y produce por debajo una copia inmutable estructuralmente compartida. Convierte la fusión superficial en escritura profunda sin propagación a mano.
devtools
Emite cada cambio a la extensión de Redux. Acepta un nombre de store para distinguir varios y una bandera para desactivarlo fuera de desarrollo.
subscribeWithSelector
Amplía api.subscribe con selector, comparador de igualdad y una opción para invocar el oyente de inmediato. Base de la suscripción granular fuera de React.
redux y combine
Dos piezas menos conocidas: redux monta un reductor clásico dentro del store, y combine infiere el tipo del estado a partir del objeto inicial.
Con immer desaparece la propagación manual que hacía ilegibles los estados anidados, pero conviene recordar qué se paga: el borrador es un proxy, y mutarlo tiene un coste superior al de una propagación trivial. Para un estado plano no aporta nada; para uno con tres niveles y colecciones, es la diferencia entre código que se lee y código que se descifra.
devtools merece una precisión que casi nadie usa: el tercer argumento de set nombra la acción en el panel. Sin él, el registro es una lista interminable de entradas anónimas y la herramienta pierde la mitad de su valor.
anadir: (l) => set((s) => { s.lineas.push(l) }, false, 'carrito/anadirLinea')
subscribeWithSelector transforma subscribe de un oyente global a uno con la misma granularidad que tienen los componentes, y añade algo que el hook no tiene: el oyente recibe el valor nuevo y el anterior, lo que permite reaccionar a transiciones y no solo a estados.
const off = useCarrito.subscribe(
(s) => s.cupon,
(nuevo, anterior) => registrarAnalitica(anterior, nuevo),
{ equalityFn: Object.is, fireImmediately: false },
)
El coste en el sistema de tipos
La composición de middleware es la razón por la que create se escribe curvada. Cada middleware declara un mutador en el tipo del store, y esos mutadores viajan en una tupla que TypeScript necesita inferir junto con el estado. Con la forma no curvada la inferencia falla; con la curvada, el estado se fija a mano y los mutadores se infieren solos.
El problema reaparece al extraer el inicializador a una variable o a un archivo aparte, algo obligatorio en cuanto usas slices. Ahí ya no hay inferencia posible y hay que declarar los mutadores explícitamente en StateCreator, cuyo tercer parámetro genérico es la tupla de mutadores aplicados por dentro.
import type { StateCreator } from 'zustand'
// el mutador de immer declarado a mano: sin esto, set no acepta el borrador
type ConImmer = [['zustand/immer', never]]
const crearCarrito: StateCreator<Carrito, ConImmer, [], Carrito> = (set) => ({
lineas: [],
anadir: (l) => set((s) => { s.lineas.push(l) }),
})
La arquitectura de middleware de Zustand es una lección de diseño que trasciende la librería, porque muestra con una nitidez poco común dónde se traslada la complejidad cuando se decide sacarla del núcleo. Al definir el punto de extensión como una transformación del inicializador y no como una lista de opciones, el núcleo consigue no saber nada de persistencia, de inmutabilidad ni de herramientas de depuración, y esa ignorancia es exactamente lo que le permite pesar lo que pesa y no versionar decisiones ajenas. Pero la complejidad no se destruye, se muda: pasa del código de la librería al orden de composición, que ahora es responsabilidad tuya y no está codificado en ninguna parte salvo en la forma en que anidas los paréntesis. Ninguna comprobación te avisa de que devtools por dentro de persist es un error; simplemente verás menos eventos de los que esperabas y tardarás una tarde en entender por qué. Y se muda también al sistema de tipos, donde la tupla de mutadores es el precio literal de que cada capa pueda alterar la firma de set sin que el compilador pierda la pista: esa tupla es un artefacto que ningún usuario querría escribir a mano y que la inferencia oculta mientras puede, hasta que extraes un slice y reaparece como un tipo críptico que hay que declarar. Reconocer ese patrón vale más que memorizar la API concreta, porque es el mismo que gobierna los plugins de un bundler, los interceptores de un cliente HTTP y los decoradores de cualquier framework: allí donde el punto de extensión es una función que envuelve a otra, la potencia es máxima y el orden se vuelve semántico, es decir, se convierte en parte del programa aunque tenga aspecto de configuración. La disciplina que compensa ese riesgo es doble y aburrida como toda buena disciplina: definir la pila una sola vez en un lugar visible del proyecto y escribir junto a ella, en dos líneas de comentario, por qué cada capa está donde está.
- Escribe un store con la pila
devtools,persisteimmeren el orden canónico y comprueba que ves la rehidratación en la extensión de Redux. - Invierte
devtoolsypersisty documenta exactamente qué eventos dejas de ver. Explica por qué a partir de la firma del middleware. - Añade
partializepara excluir un campo efímero y verifica en el almacén del navegador que ya no se guarda. Comprueba también que las funciones nunca estuvieron ahí. - Sube
versiona 2 cambiando la forma del estado y escribe la funciónmigrate. Simula un usuario con datos viejos escribiendo a mano el valor guardado y confirma que la migración lo repara. - Activa
skipHydration, rehidrata manualmente en un efecto y comprueba que desaparece el desajuste de hidratación en una página renderizada en servidor. - Nombra todas tus acciones con el tercer argumento de
sety compara el panel de la extensión antes y después. Decide si el ruido que añade compensa la trazabilidad que da.