React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zustandpersistlocalStoragesessionStoragestorage

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

  • persist middleware intercepts every set call and writes the new state to storage after the state update.
  • On store creation, 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.
  • 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 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;
  },
});

Gotchas

  • Hydration is asynchronous. Components may briefly render with default state before persisted state loads. Use onFinishHydration or hasHydrated() to gate rendering.
  • Functions in state are serialized as null by JSON.stringify. Always use partialize to 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.
  • 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.
  • 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 storage event for cross-tab sync.

Alternatives

ApproachProsCons
localStorage (default)Simple, synchronous reads5MB limit, blocks main thread
sessionStorageAuto-clears on tab closeNot shared across tabs
IndexedDBLarge capacity, asyncMore complex setup
Cookie storageAvailable 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 name option (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 onFinishHydration or hasHydrated() to gate rendering until hydration completes.
What does partialize do and why should you use it?
  • partialize controls 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 version number on the persist config.
  • Provide a migrate function that transforms old state to the new shape.
  • migrate runs when the stored version is less than the current version.
How do you use a custom storage engine like IndexedDB?
  • Implement the StateStorage interface (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 storage event 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 a useEffect to set a hydrated flag.
  • 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?
  • migrate receives unknown for the persisted state and a number for 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 the storage option.