React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zustandmiddlewarepersistdevtoolsimmer

Zustand Middleware

Recipe

Zustand ships with several built-in middleware: persist for storage, devtools for Redux DevTools, immer for immutable updates, and subscribeWithSelector for granular subscriptions. Stack them by nesting.

import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
 
interface AppState {
  count: number;
  increment: () => void;
}
 
export const useStore = create<AppState>()(
  devtools(
    persist(
      immer((set) => ({
        count: 0,
        increment: () =>
          set((state) => {
            state.count += 1; // Direct mutation thanks to immer
          }),
      })),
      { name: "app-store" }
    ),
    { name: "AppStore" }
  )
);

Working Example

// stores/settings-store.ts
import { create } from "zustand";
import { devtools, persist, subscribeWithSelector } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
 
interface Settings {
  theme: "light" | "dark" | "system";
  fontSize: number;
  language: string;
  notifications: {
    email: boolean;
    push: boolean;
    sms: boolean;
  };
}
 
interface SettingsStore extends Settings {
  setTheme: (theme: Settings["theme"]) => void;
  setFontSize: (size: number) => void;
  setLanguage: (lang: string) => void;
  toggleNotification: (channel: keyof Settings["notifications"]) => void;
  resetToDefaults: () => void;
}
 
const defaults: Settings = {
  theme: "system",
  fontSize: 16,
  language: "en",
  notifications: { email: true, push: true, sms: false },
};
 
export const useSettingsStore = create<SettingsStore>()(
  devtools(
    subscribeWithSelector(
      persist(
        immer((set) => ({
          ...defaults,
 
          setTheme: (theme) =>
            set((state) => {
              state.theme = theme;
            }),
 
          setFontSize: (size) =>
            set((state) => {
              state.fontSize = Math.max(12, Math.min(24, size));
            }),
 
          setLanguage: (lang) =>
            set((state) => {
              state.language = lang;
            }),
 
          toggleNotification: (channel) =>
            set((state) => {
              state.notifications[channel] = !state.notifications[channel];
            }),
 
          resetToDefaults: () =>
            set((state) => {
              Object.assign(state, defaults);
            }),
        })),
        {
          name: "settings-storage",
          partialize: (state) => ({
            theme: state.theme,
            fontSize: state.fontSize,
            language: state.language,
            notifications: state.notifications,
          }),
        }
      )
    ),
    { name: "SettingsStore" }
  )
);
 
// Subscribe to specific state changes
useSettingsStore.subscribe(
  (state) => state.theme,
  (theme) => {
    document.documentElement.setAttribute("data-theme", theme);
  }
);
 
useSettingsStore.subscribe(
  (state) => state.fontSize,
  (fontSize) => {
    document.documentElement.style.fontSize = `${fontSize}px`;
  }
);
// components/settings-panel.tsx
"use client";
 
import { useSettingsStore } from "@/stores/settings-store";
 
export function SettingsPanel() {
  const theme = useSettingsStore((s) => s.theme);
  const fontSize = useSettingsStore((s) => s.fontSize);
  const notifications = useSettingsStore((s) => s.notifications);
  const setTheme = useSettingsStore((s) => s.setTheme);
  const setFontSize = useSettingsStore((s) => s.setFontSize);
  const toggleNotification = useSettingsStore((s) => s.toggleNotification);
  const resetToDefaults = useSettingsStore((s) => s.resetToDefaults);
 
  return (
    <div>
      <section>
        <h3>Theme</h3>
        <select value={theme} onChange={(e) => setTheme(e.target.value as any)}>
          <option value="light">Light</option>
          <option value="dark">Dark</option>
          <option value="system">System</option>
        </select>
      </section>
 
      <section>
        <h3>Font Size: {fontSize}px</h3>
        <input
          type="range"
          min={12}
          max={24}
          value={fontSize}
          onChange={(e) => setFontSize(Number(e.target.value))}
        />
      </section>
 
      <section>
        <h3>Notifications</h3>
        {(Object.keys(notifications) as Array<keyof typeof notifications>).map((ch) => (
          <label key={ch}>
            <input
              type="checkbox"
              checked={notifications[ch]}
              onChange={() => toggleNotification(ch)}
            />
            {ch}
          </label>
        ))}
      </section>
 
      <button onClick={resetToDefaults}>Reset to defaults</button>
    </div>
  );
}

Deep Dive

How It Works

  • Middleware wraps the store creator function, adding behavior before or after state changes.
  • persist: Serializes state to storage (localStorage by default) and rehydrates on mount.
  • devtools: Connects to Redux DevTools browser extension for time-travel debugging.
  • immer: Wraps set so you can write mutable-style updates that produce immutable state.
  • subscribeWithSelector: Extends .subscribe() to accept a selector and equality function, enabling granular side effects.
  • Middleware is applied from inside out. The innermost middleware runs first.

Variations

Middleware ordering (recommended):

// devtools outermost, then subscribeWithSelector, then persist, then immer innermost
create<Store>()(
  devtools(
    subscribeWithSelector(
      persist(
        immer((set) => ({ ... })),
        { name: "store" }
      )
    )
  )
);

Custom middleware:

import { StateCreator, StoreMutatorIdentifier } from "zustand";
 
type Logger = <
  T,
  Mps extends [StoreMutatorIdentifier, unknown][] = [],
  Mcs extends [StoreMutatorIdentifier, unknown][] = []
>(
  f: StateCreator<T, Mps, Mcs>,
  name?: string
) => StateCreator<T, Mps, Mcs>;
 
const logger: Logger = (f, name) => (set, get, store) => {
  const loggedSet: typeof set = (...args) => {
    set(...(args as any));
    console.log(`[${name || "store"}]`, get());
  };
  return f(loggedSet, get, store);
};
 
// Usage
const useStore = create(logger((set) => ({ count: 0 }), "CountStore"));

TypeScript Notes

  • When stacking middleware, use create<State>()() (double invocation) to help TypeScript infer the combined types.
  • Each middleware adds its own type mutators. The order affects type inference.
  • partialize in persist middleware must return a type-safe subset of the state.
// The ()() pattern is required for middleware type inference
export const useStore = create<State>()(
  devtools(persist(immer((set) => ({ ... })), { name: "store" }))
);

Gotchas

  • Middleware order matters for both behavior and TypeScript. Always put devtools outermost and immer innermost.
  • Using immer without the middleware import from zustand/middleware/immer will not work. It is a separate package.
  • persist serializes state with JSON.stringify by default. Functions, Dates, Maps, and Sets will be lost. Use partialize to exclude non-serializable values.
  • devtools adds overhead. Disable it in production: devtools(fn, { enabled: process.env.NODE_ENV === "development" }).
  • subscribeWithSelector must wrap persist (not the other way around) if you want to subscribe to persisted state changes.
  • Stacking too many middleware makes debugging harder. Only add middleware you actually need.

Alternatives

ApproachProsCons
Built-in Zustand middlewareOfficial support, well-typedFixed set of middleware
Custom middlewareTailored to your needsMust handle types manually
No middleware (plain store)Simplest, fastestNo persistence, devtools, or immer
Redux Toolkit middlewareMature ecosystemDifferent library, more boilerplate

FAQs

What built-in middleware does Zustand provide?
  • persist -- saves and restores state to storage (localStorage by default).
  • devtools -- connects to Redux DevTools browser extension.
  • immer -- enables mutable-style state updates that produce immutable state.
  • subscribeWithSelector -- allows subscribing to specific state slices outside React.
What is the recommended middleware stacking order?
  • devtools outermost, then subscribeWithSelector, then persist, then immer innermost.
  • Middleware is applied from inside out: the innermost runs first.
Why is the double invocation create<State>()() needed with middleware?
  • The first () helps TypeScript correctly infer the combined middleware types.
  • Without it, TypeScript cannot thread type information through the middleware chain.
How does subscribeWithSelector differ from the default .subscribe()?
  • It extends .subscribe() to accept a selector and equality function.
  • You can subscribe to a specific field and only fire the callback when that field changes.
useStore.subscribe(
  (state) => state.theme,
  (theme) => document.documentElement.setAttribute("data-theme", theme)
);
How do you write custom middleware?
  • Custom middleware wraps the store creator function and intercepts set, get, or store.
  • Return a new store creator that adds behavior before or after state changes.
Gotcha: What happens if you put immer outside persist instead of inside?
  • Immer must be the innermost middleware. Placing it outside persist or devtools will not work correctly.
  • The mutable draft proxy will not function as expected if another middleware wraps it.
Gotcha: Does persist serialize functions in state?
  • Yes, JSON.stringify serializes functions as null.
  • Use partialize to exclude actions and non-serializable values.
  • Functions lost during serialization are restored from defaults on rehydration.
How do you disable devtools middleware in production?
devtools(storeCreator, {
  name: "AppStore",
  enabled: process.env.NODE_ENV === "development",
});
Must subscribeWithSelector wrap persist or vice versa?
  • subscribeWithSelector must wrap persist (not the other way around) if you want to subscribe to persisted state changes.
How does each middleware affect the TypeScript types?
  • Each middleware adds its own type mutators to the store's type chain.
  • The order of middleware affects type inference, which is why consistent ordering matters.
  • Use create<State>()() to let TypeScript infer the combined types correctly.
How does partialize work in the persist middleware?
persist(storeCreator, {
  name: "settings-storage",
  partialize: (state) => ({
    theme: state.theme,
    fontSize: state.fontSize,
  }),
});
  • Only the returned properties are saved to storage. Everything else is excluded.