Middleware de persistencia
Receta
Usa el middleware persist para guardar y restaurar automáticamente el state del store en localStorage, sessionStorage o cualquier motor de almacenamiento personalizado.
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface PreferencesStore {
language: string;
currency: string;
setLanguage: (lang: string) => void;
setCurrency: (currency: string) => void;
}
export const usePreferencesStore = create<PreferencesStore>()(
persist(
(set) => ({
language: "en",
currency: "USD",
setLanguage: (language) => set({ language }),
setCurrency: (currency) => set({ currency }),
}),
{
name: "user-preferences", // clave de localStorage
}
)
);Ejemplo funcional
// stores/app-store.ts
import { create } from "zustand";
import { persist, createJSONStorage, StateStorage } from "zustand/middleware";
interface AppState {
user: { name: string; email: string } | null;
theme: "light" | "dark";
recentSearches: string[];
setUser: (user: AppState["user"]) => void;
setTheme: (theme: AppState["theme"]) => void;
addSearch: (query: string) => void;
clearSearches: () => void;
}
// Motor de almacenamiento personalizado con IndexedDB mediante idb-keyval
const indexedDBStorage: StateStorage = {
getItem: async (name) => {
const { get } = await import("idb-keyval");
return (await get(name)) || null;
},
setItem: async (name, value) => {
const { set } = await import("idb-keyval");
await set(name, value);
},
removeItem: async (name) => {
const { del } = await import("idb-keyval");
await del(name);
},
};
export const useAppStore = create<AppState>()(
persist(
(set) => ({
user: null,
theme: "light",
recentSearches: [],
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
addSearch: (query) =>
set((state) => ({
recentSearches: [
query,
...state.recentSearches.filter((s) => s !== query),
].slice(0, 10),
})),
clearSearches: () => set({ recentSearches: [] }),
}),
{
name: "app-storage",
storage: createJSONStorage(() => indexedDBStorage),
partialize: (state) => ({
theme: state.theme,
recentSearches: state.recentSearches,
// Excluir user — volver a obtener desde la API al cargar
}),
version: 2,
migrate: (persistedState: any, version) => {
if (version === 0) {
// v0 tenía "darkMode: boolean" en lugar de "theme"
persistedState.theme = persistedState.darkMode ? "dark" : "light";
delete persistedState.darkMode;
}
if (version < 2) {
// v1 tenía "searches" en lugar de "recentSearches"
persistedState.recentSearches = persistedState.searches || [];
delete persistedState.searches;
}
return persistedState;
},
}
)
);// components/hydration-gate.tsx
"use client";
import { useEffect, useState } from "react";
import { useAppStore } from "@/stores/app-store";
export function HydrationGate({ children }: { children: React.ReactNode }) {
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
// Esperar a la rehidratación de persist
const unsub = useAppStore.persist.onFinishHydration(() => {
setHydrated(true);
});
// Si ya está hidratado
if (useAppStore.persist.hasHydrated()) {
setHydrated(true);
}
return unsub;
}, []);
if (!hydrated) return <div>Cargando...</div>;
return <>{children}</>;
}Análisis profundo
Cómo funciona
- El middleware
persistintercepta cada llamada asety escribe el nuevo state en el almacenamiento después de la actualización del state. - Al crear el store,
persistlee del almacenamiento y fusiona el state persistido con el state por defecto. partializecontrola qué propiedades del state se guardan. Por defecto, se serializa todo el state (incluidas las funciones).versionymigratepermiten la evolución del esquema. Cuando la versión almacenada es menor que la versión actual,migratetransforma el state antiguo.createJSONStorageenvuelve una interfazStateStoragecon serialización y deserialización JSON.- La hidratación es async. El state comienza con los valores por defecto y luego se actualiza una vez que se lee el almacenamiento.
Variaciones
sessionStorage:
import { createJSONStorage } from "zustand/middleware";
persist(storeCreator, {
name: "session-store",
storage: createJSONStorage(() => sessionStorage),
});Persistencia selectiva con merge:
persist(storeCreator, {
name: "store",
partialize: (state) => ({ theme: state.theme, lang: state.lang }),
merge: (persistedState, currentState) => ({
...currentState,
...(persistedState as Partial<State>),
}),
});Almacenamiento cifrado:
const encryptedStorage: StateStorage = {
getItem: (name) => {
const raw = localStorage.getItem(name);
if (!raw) return null;
return decrypt(raw);
},
setItem: (name, value) => {
localStorage.setItem(name, encrypt(value));
},
removeItem: (name) => {
localStorage.removeItem(name);
},
};Borrar datos persistidos:
// Borrar el state persistido de forma programática
useAppStore.persist.clearStorage();
// O manualmente
localStorage.removeItem("app-storage");Notas de TypeScript
- El middleware
persistpreserva la firma de tipos del store. partializedebe devolver un partial con tipos seguros. UsaPick<State, keys>para un tipado explícito.migraterecibeunknownpara el state persistido. Haz el cast con cuidado.
persist<AppState>(storeCreator, {
name: "store",
partialize: (state): Pick<AppState, "theme" | "language"> => ({
theme: state.theme,
language: state.language,
}),
migrate: (persisted: unknown, version: number) => {
const state = persisted as Partial<AppState>;
return state as AppState;
},
});Trampas comunes
- La hidratación es asíncrona. Los componentes pueden renderizar brevemente con el state por defecto antes de que se cargue el state persistido. Usa
onFinishHydrationohasHydrated()para controlar el renderizado. - Las funciones en el state se serializan como
nullmedianteJSON.stringify. Usa siemprepartializepara excluir acciones, o se perderán y se reemplazarán por los valores por defecto en la rehidratación. - localStorage tiene un límite de ~5MB por origen. Los stores grandes pueden fallar al persistir de forma silenciosa. Monitoriza el uso del almacenamiento.
versiontiene por defecto 0. Si no defines una versión y luego necesitas migraciones, debes empezar desde la versión 0 en tu función migrate.- El HTML renderizado en el servidor usa el state por defecto. Si el state persistido difiere (p. ej., tema oscuro), habrá un flash de contenido incorrecto. Gestiona esto con una compuerta de hidratación o detección de tema basada en CSS.
- Varias pestañas pueden sobrescribir el state persistido de las demás. Gana la última pestaña en escribir. Considera el evento
storagepara sincronización entre pestañas.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| localStorage (predeterminado) | Simple, lecturas sincrónicas | Límite de 5MB, bloquea el hilo principal |
| sessionStorage | Se borra automáticamente al cerrar la pestaña | No se comparte entre pestañas |
| IndexedDB | Gran capacidad, async | Configuración más compleja |
| Cookie storage | Disponible en el servidor (SSR) | Límite de 4KB, se envía con cada solicitud |
Preguntas frecuentes
¿Cuál es la configuración mínima necesaria para usar el middleware persist?
import { persist } from "zustand/middleware";
create<MyState>()(
persist(storeCreator, { name: "storage-key" })
);- Solo la opción
name(la clave de localStorage) es obligatoria.
¿Cómo funciona la hidratación y por qué provoca un breve flash del state por defecto?
- Persist lee del almacenamiento de forma asíncrona después de crear el store.
- Los componentes renderizan inicialmente con el state por defecto y luego se actualizan una vez que se carga el state persistido.
- Usa
onFinishHydrationohasHydrated()para controlar el renderizado hasta que la hidratación termine.
¿Qué hace partialize y por qué deberías usarlo?
partializecontrola qué propiedades del state se guardan en el almacenamiento.- Por defecto, se serializa todo el state (incluidas las funciones).
- Úsalo siempre para excluir acciones y valores no serializables.
¿Cómo gestionas las migraciones de esquema cuando cambia la forma del state persistido?
- Define un número de
versionen la configuración de persist. - Proporciona una función
migrateque transforme el state antiguo a la nueva forma. migratese ejecuta cuando la versión almacenada es menor que la versión actual.
¿Cómo usas un motor de almacenamiento personalizado como IndexedDB?
- Implementa la interfaz
StateStorage(getItem,setItem,removeItem). - Envuélvela con
createJSONStorage(() => yourStorage). - Esto admite motores de almacenamiento tanto sync como async.
Trampa: ¿Qué ocurre si localStorage supera su límite de ~5MB?
- La escritura falla de forma silenciosa. El state no se persiste.
- Monitoriza el uso del almacenamiento para stores grandes o usa IndexedDB para mayor capacidad.
Trampa: ¿Pueden varias pestañas sobrescribir el state persistido de las demás?
- Sí. Gana la última pestaña en escribir.
- Considera escuchar el evento
storagepara sincronización entre pestañas. - No hay sincronización entre pestañas integrada en el middleware persist de Zustand.
¿Cómo borras los datos persistidos de forma programática?
useAppStore.persist.clearStorage();
// O manualmente:
localStorage.removeItem("app-storage");¿Cómo esperas a la hidratación antes de renderizar en una app Next.js?
- Usa
useAppStore.persist.onFinishHydration()en unuseEffectpara establecer un flaghydrated. - Comprueba
useAppStore.persist.hasHydrated()para verificaciones sincrónicas. - Renderiza un estado de carga hasta que la hidratación termine.
¿Cómo tipas la opción partialize en TypeScript?
persist<AppState>(storeCreator, {
name: "store",
partialize: (state): Pick<AppState, "theme" | "language"> => ({
theme: state.theme,
language: state.language,
}),
});¿Qué tipo recibe la función migrate en TypeScript?
migraterecibeunknownpara el state persistido y unnumberpara la versión.- Debes hacer el cast con cuidado:
const state = persisted as Partial<AppState>.
¿Cuál es la diferencia entre localStorage y sessionStorage para persist?
- localStorage persiste entre sesiones del navegador y pestañas.
- sessionStorage se borra al cerrar la pestaña y no se comparte entre pestañas.
- Cambia pasando
createJSONStorage(() => sessionStorage)como opciónstorage.