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
setso 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.
partializein 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
devtoolsoutermost andimmerinnermost. - Using
immerwithout the middleware import fromzustand/middleware/immerwill not work. It is a separate package. persistserializes state withJSON.stringifyby default. Functions, Dates, Maps, and Sets will be lost. Usepartializeto exclude non-serializable values.devtoolsadds overhead. Disable it in production:devtools(fn, { enabled: process.env.NODE_ENV === "development" }).subscribeWithSelectormust wrappersist(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
| Approach | Pros | Cons |
|---|---|---|
| Built-in Zustand middleware | Official support, well-typed | Fixed set of middleware |
| Custom middleware | Tailored to your needs | Must handle types manually |
| No middleware (plain store) | Simplest, fastest | No persistence, devtools, or immer |
| Redux Toolkit middleware | Mature ecosystem | Different 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?
devtoolsoutermost, thensubscribeWithSelector, thenpersist, thenimmerinnermost.- 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, orstore. - 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
persistordevtoolswill 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.stringifyserializes functions asnull. - Use
partializeto 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?
subscribeWithSelectormust wrappersist(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.