//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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
}
)
);// 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}</>;
}persist middleware intercepts every set call and writes the new state to storage after the state update.persist reads from storage and merges the persisted state with the default state.partialize controls which state properties are saved. By default, the entire state (including functions) is serialized.version and migrate enable schema evolution. When the stored version is less than the current version, migrate transforms the old state.createJSONStorage wraps a StateStorage interface with JSON serialization and deserialization.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");persist middleware preserves the store's type signature.partialize should return a type-safe partial. Use Pick<State, keys> for explicit typing.migrate receives unknown for 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;
},
});onFinishHydration or hasHydrated() to gate rendering.null by JSON.stringify. Always use partialize to exclude actions, or they will be lost and replaced by the defaults on rehydration.version defaults to 0. If you do not set a version and later need migrations, you must start from version 0 in your migrate function.storage event for cross-tab sync.| 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 |
import { persist } from "zustand/middleware";
create<MyState>()(
persist(storeCreator, { name: "storage-key" })
);name option (the localStorage key) is required.onFinishHydration or hasHydrated() to gate rendering until hydration completes.partialize controls which state properties are saved to storage.version number on the persist config.migrate function that transforms old state to the new shape.migrate runs when the stored version is less than the current version.StateStorage interface (getItem, setItem, removeItem).createJSONStorage(() => yourStorage).storage event for cross-tab synchronization.useAppStore.persist.clearStorage();
// Or manually:
localStorage.removeItem("app-storage");useAppStore.persist.onFinishHydration() in a useEffect to set a hydrated flag.useAppStore.persist.hasHydrated() for synchronous checks.persist<AppState>(storeCreator, {
name: "store",
partialize: (state): Pick<AppState, "theme" | "language"> => ({
theme: state.theme,
language: state.language,
}),
});migrate receives unknown for the persisted state and a number for the version.const state = persisted as Partial<AppState>.createJSONStorage(() => sessionStorage) as the storage option.Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥