Persist Middleware
Recipe
Use the persist middleware to automatically save and restore store state to localStorage, sessionStorage, or any custom storage engine.
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", // localStorage key
}
)
);Working Example
// 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;
}
// Custom storage engine using IndexedDB via 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,
// Exclude user — re-fetch from API on load
}),
version: 2,
migrate: (persistedState: any, version) => {
if (version === 0) {
// v0 had "darkMode: boolean" instead of "theme"
persistedState.theme = persistedState.darkMode ? "dark" : "light";
delete persistedState.darkMode;
}
if (version < 2) {
// v1 had "searches" instead of "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(() => {
// Wait for persist rehydration
const unsub = useAppStore.persist.onFinishHydration(() => {
setHydrated(true);
});
// If already hydrated
if (useAppStore.persist.hasHydrated()) {
setHydrated(true);
}
return unsub;
}, []);
if (!hydrated) return <div>Loading...</div>;
return <>{children}</>;
}Deep Dive
How It Works
persistmiddleware intercepts everysetcall and writes the new state to storage after the state update.- On store creation,
persistreads from storage and merges the persisted state with the default state. partializecontrols which state properties are saved. By default, the entire state (including functions) is serialized.versionandmigrateenable schema evolution. When the stored version is less than the current version,migratetransforms the old state.createJSONStoragewraps aStateStorageinterface with JSON serialization and deserialization.- Hydration is async. State starts with defaults, then updates once storage is read.
Variations
sessionStorage:
import { createJSONStorage } from "zustand/middleware";
persist(storeCreator, {
name: "session-store",
storage: createJSONStorage(() => sessionStorage),
});Selective persistence with merge:
persist(storeCreator, {
name: "store",
partialize: (state) => ({ theme: state.theme, lang: state.lang }),
merge: (persistedState, currentState) => ({
...currentState,
...(persistedState as Partial<State>),
}),
});Encrypted storage:
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);
},
};Clear persisted data:
// Programmatically clear persisted state
useAppStore.persist.clearStorage();
// Or manually
localStorage.removeItem("app-storage");TypeScript Notes
- The
persistmiddleware preserves the store's type signature. partializeshould return a type-safe partial. UsePick<State, keys>for explicit typing.migratereceivesunknownfor the persisted state. Cast carefully.
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;
},
});Gotchas
- Hydration is asynchronous. Components may briefly render with default state before persisted state loads. Use
onFinishHydrationorhasHydrated()to gate rendering. - Functions in state are serialized as
nullbyJSON.stringify. Always usepartializeto exclude actions, or they will be lost and replaced by the defaults on rehydration. - localStorage has a ~5MB limit per origin. Large stores can silently fail to persist. Monitor storage usage.
versiondefaults to 0. If you do not set a version and later need migrations, you must start from version 0 in your migrate function.- Server-rendered HTML uses default state. If persisted state differs (e.g., dark theme), there will be a flash of wrong content. Handle this with a hydration gate or CSS-based theme detection.
- Multiple tabs can overwrite each other's persisted state. The last tab to write wins. Consider the
storageevent for cross-tab sync.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| localStorage (default) | Simple, synchronous reads | 5MB limit, blocks main thread |
| sessionStorage | Auto-clears on tab close | Not shared across tabs |
| IndexedDB | Large capacity, async | More complex setup |
| Cookie storage | Available on server (SSR) | 4KB limit, sent with every request |
FAQs
What is the minimum configuration needed to use the persist middleware?
import { persist } from "zustand/middleware";
create<MyState>()(
persist(storeCreator, { name: "storage-key" })
);- Only the
nameoption (the localStorage key) is required.
How does hydration work and why does it cause a brief flash of default state?
- Persist reads from storage asynchronously after store creation.
- Components initially render with default state, then update once the persisted state loads.
- Use
onFinishHydrationorhasHydrated()to gate rendering until hydration completes.
What does partialize do and why should you use it?
partializecontrols which state properties are saved to storage.- By default, the entire state (including functions) is serialized.
- Always use it to exclude actions and non-serializable values.
How do you handle schema migrations when persisted state changes shape?
- Set a
versionnumber on the persist config. - Provide a
migratefunction that transforms old state to the new shape. migrateruns when the stored version is less than the current version.
How do you use a custom storage engine like IndexedDB?
- Implement the
StateStorageinterface (getItem,setItem,removeItem). - Wrap it with
createJSONStorage(() => yourStorage). - This supports both sync and async storage engines.
Gotcha: What happens if localStorage exceeds its ~5MB limit?
- The write silently fails. The state is not persisted.
- Monitor storage usage for large stores or use IndexedDB for larger capacity.
Gotcha: Can multiple tabs overwrite each other's persisted state?
- Yes. The last tab to write wins.
- Consider listening to the
storageevent for cross-tab synchronization. - There is no built-in cross-tab sync in Zustand's persist middleware.
How do you programmatically clear persisted data?
useAppStore.persist.clearStorage();
// Or manually:
localStorage.removeItem("app-storage");How do you wait for hydration before rendering in a Next.js app?
- Use
useAppStore.persist.onFinishHydration()in auseEffectto set ahydratedflag. - Check
useAppStore.persist.hasHydrated()for synchronous checks. - Render a loading state until hydration completes.
How do you type the partialize option in TypeScript?
persist<AppState>(storeCreator, {
name: "store",
partialize: (state): Pick<AppState, "theme" | "language"> => ({
theme: state.theme,
language: state.language,
}),
});What type does the migrate function receive in TypeScript?
migratereceivesunknownfor the persisted state and anumberfor the version.- You must cast carefully:
const state = persisted as Partial<AppState>.
What is the difference between localStorage and sessionStorage for persist?
- localStorage persists across browser sessions and tabs.
- sessionStorage is cleared when the tab closes and is not shared across tabs.
- Switch by passing
createJSONStorage(() => sessionStorage)as thestorageoption.