//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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" }
)
);// 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>
);
}set so you can write mutable-style updates that produce immutable state..subscribe() to accept a selector and equality function, enabling granular side effects.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"));create<State>()() (double invocation) to help TypeScript infer the combined types.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" }))
);devtools outermost and immer innermost.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.| 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 |
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.devtools outermost, then subscribeWithSelector, then persist, then immer innermost.() helps TypeScript correctly infer the combined middleware types..subscribe() to accept a selector and equality function.useStore.subscribe(
(state) => state.theme,
(theme) => document.documentElement.setAttribute("data-theme", theme)
);set, get, or store.persist or devtools will not work correctly.JSON.stringify serializes functions as null.partialize to exclude actions and non-serializable values.devtools(storeCreator, {
name: "AppStore",
enabled: process.env.NODE_ENV === "development",
});subscribeWithSelector must wrap persist (not the other way around) if you want to subscribe to persisted state changes.create<State>()() to let TypeScript infer the combined types correctly.persist(storeCreator, {
name: "settings-storage",
partialize: (state) => ({
theme: state.theme,
fontSize: state.fontSize,
}),
});Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥