Zustand Guide 2026: Slices, Immer Middleware, DevTools, Persist
Everything you need to use Zustand properly in 2026 — slices pattern, Immer middleware, Redux DevTools wiring, and persist with custom storage.
Why Zustand Is Still Winning in 2026
Zustand 5.0 shipped in late 2024 and hasn't looked back. It's tiny — around 1.1 kB gzipped — zero context provider boilerplate, and the API is so minimal that you can onboard a junior dev in under 20 minutes. That's not hype. That's why teams keep choosing it over Redux Toolkit even when RTK has better tooling on paper.
Honestly, the reason most teams reach for Zustand isn't the bundle size. It's the cognitive simplicity. You define a store, you read from it, you mutate it — and you never write mapStateToProps again. Redux still has a place for enormous apps with complex middleware pipelines, but for the 80% of React projects out there, Zustand just gets out of your way.
Worth noting: Zustand's subscription model is pull-based, not push-based. Your component re-renders only when the slice of state it subscribed to actually changed. No memoization ceremony, no shallowEqual import, no useCallback wrapper around every selector. This behavior is baked in as of Zustand 4.x and hasn't changed in 5.
In this guide you'll wire up a real Zustand store with the slices pattern, drop in Immer middleware so you can write mutations that look like direct assignments, connect Redux DevTools for time-travel debugging, and persist state across page reloads using the persist middleware. If you're using Empire UI's components alongside this — browse components to see what's available — you'll want stable client-side state that doesn't blow up on hydration. We'll cover that too.
Creating Your First Store
Install Zustand if you haven't already. As of writing this the latest stable release is 5.0.3:
npm install zustand
# or
pnpm add zustandA bare-minimum store looks like this:
// store/useCounterStore.ts
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));Using it in a component takes three lines — import the hook, call it with a selector, done. The selector prevents re-renders when unrelated state changes:
// components/Counter.tsx
import { useCounterStore } from '../store/useCounterStore';
export function Counter() {
const count = useCounterStore((s) => s.count);
const { increment, decrement, reset } = useCounterStore();
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
<button onClick={reset}>reset</button>
</div>
);
}That said, real apps don't live in a single counter. You'll quickly want to split concerns — user auth, UI preferences, async data — into separate logical units. That's where slices come in.
The Slices Pattern: Splitting a Large Store
The slices pattern is the community-endorsed way to organize complex stores without splitting them into totally separate atoms (which makes cross-slice access awkward). The idea: define each slice as a function that receives set, get, and the store itself, then merge them in one create call.
Here's a two-slice store handling auth and UI preferences:
// store/slices/authSlice.ts
import { StateCreator } from 'zustand';
import { StoreState } from '../useAppStore';
export interface AuthSlice {
user: { id: string; name: string } | null;
isAuthenticated: boolean;
login: (user: { id: string; name: string }) => void;
logout: () => void;
}
export const createAuthSlice: StateCreator<
StoreState,
[],
[],
AuthSlice
> = (set) => ({
user: null,
isAuthenticated: false,
login: (user) => set({ user, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
});// store/slices/uiSlice.ts
import { StateCreator } from 'zustand';
import { StoreState } from '../useAppStore';
export interface UISlice {
theme: 'light' | 'dark';
sidebarOpen: boolean;
toggleTheme: () => void;
setSidebarOpen: (open: boolean) => void;
}
export const createUISlice: StateCreator<
StoreState,
[],
[],
UISlice
> = (set, get) => ({
theme: 'dark',
sidebarOpen: true,
toggleTheme: () =>
set({ theme: get().theme === 'dark' ? 'light' : 'dark' }),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
});// store/useAppStore.ts
import { create } from 'zustand';
import { AuthSlice, createAuthSlice } from './slices/authSlice';
import { UISlice, createUISlice } from './slices/uiSlice';
export type StoreState = AuthSlice & UISlice;
export const useAppStore = create<StoreState>()((...a) => ({
...createAuthSlice(...a),
...createUISlice(...a),
}));Notice the (...a) spread — that's important. Each StateCreator receives set, get, and the raw store, and merging via spread composition gives every slice full type-safe access to the combined state via get(). If you need an auth slice action to also close the sidebar, get().setSidebarOpen(false) just works.
Immer Middleware: Write Mutations That Look Like Mutations
Zustand's default set requires you to return a new object. That's fine for flat state, but once you have nested objects it becomes painful fast. Immer middleware lets you write mutations directly — it handles the immutability under the hood using structural sharing.
npm install immer// store/useCartStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface CartItem {
id: string;
name: string;
qty: number;
price: number;
}
interface CartState {
items: CartItem[];
addItem: (item: Omit<CartItem, 'qty'>) => void;
removeItem: (id: string) => void;
updateQty: (id: string, qty: number) => void;
clearCart: () => void;
}
export const useCartStore = create<CartState>()(immer((set) => ({
items: [],
addItem: (item) => set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
existing.qty += 1; // direct mutation — Immer handles it
} else {
state.items.push({ ...item, qty: 1 });
}
}),
removeItem: (id) => set((state) => {
state.items = state.items.filter((i) => i.id !== id);
}),
updateQty: (id, qty) => set((state) => {
const item = state.items.find((i) => i.id === id);
if (item) item.qty = qty;
}),
clearCart: () => set((state) => { state.items = []; }),
})));Look at addItem. Without Immer you'd have to map over the array, find the item, spread it with an updated qty, and return a whole new array. With Immer you just write existing.qty += 1 and move on. The resulting state is still immutable — Immer produces a new object — but the authoring experience is dramatically cleaner.
One more thing — Immer and the slices pattern compose fine. Just apply immer as the outermost middleware wrapper in your create call and pass immer-typed StateCreator generics. The Zustand docs show this exact pattern and it works cleanly as of 5.0.
Redux DevTools Integration
Time-travel debugging is one of the things Redux got undeniably right. Zustand ships devtools middleware that plugs directly into the Redux DevTools browser extension, so you get that same timeline without any Redux setup.
// store/useAppStore.ts — with devtools + slices
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { AuthSlice, createAuthSlice } from './slices/authSlice';
import { UISlice, createUISlice } from './slices/uiSlice';
export type StoreState = AuthSlice & UISlice;
export const useAppStore = create<StoreState>()(
devtools(
(...a) => ({
...createAuthSlice(...a),
...createUISlice(...a),
}),
{ name: 'AppStore', enabled: process.env.NODE_ENV !== 'production' }
)
);The name option labels the store in the DevTools panel — useful when you have multiple stores open at once. The enabled guard is important: devtools middleware has overhead you don't want in production builds. Setting enabled: process.env.NODE_ENV !== 'production' keeps it out of your prod bundle.
Quick aside: each action you dispatch shows up in the DevTools timeline as anonymous by default unless you name it. To get readable action names, pass a second argument to set:
login: (user) => set({ user, isAuthenticated: true }, false, 'auth/login'),
logout: () => set({ user: null, isAuthenticated: false }, false, 'auth/logout'),That middle false tells Zustand not to replace the whole state (keep false unless you really want a full reset). The string at the end is the action label. Your DevTools timeline will now show auth/login and auth/logout — much easier to debug than a wall of anonymous events.
Persist Middleware: Survive Page Reloads
Persisting state to localStorage is the most common Zustand use case after devtools. The persist middleware handles serialization, rehydration, and partial state selection out of the box. Here's a full setup with a custom storage adapter and selective persistence:
// store/useSettingsStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface SettingsState {
theme: 'light' | 'dark';
language: 'en' | 'fr' | 'es';
reducedMotion: boolean;
setTheme: (t: 'light' | 'dark') => void;
setLanguage: (l: 'en' | 'fr' | 'es') => void;
setReducedMotion: (v: boolean) => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'dark',
language: 'en',
reducedMotion: false,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
setReducedMotion: (reducedMotion) => set({ reducedMotion }),
}),
{
name: 'empire-settings', // localStorage key
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
// only persist what matters — skip actions
theme: state.theme,
language: state.language,
reducedMotion: state.reducedMotion,
}),
version: 2, // bump this when the shape changes
migrate: (persisted: any, version: number) => {
if (version === 1) {
// handle old shape: v1 stored 'colorMode' instead of 'theme'
return { ...persisted, theme: persisted.colorMode ?? 'dark' };
}
return persisted;
},
}
)
);The partialize option is one you don't want to skip. Without it, Zustand tries to serialize your action functions to JSON, which fails silently. Always filter to plain data only.
The version + migrate combo is how you handle schema changes without wiping user preferences. Bump version whenever your state shape changes and write a migration for every older version. In practice, most teams forget this until they've already broken a user's persisted state in production — don't be that team.
Worth noting: persist middleware works with any Storage-compatible object, not just localStorage. You can swap in sessionStorage, an IndexedDB wrapper, or even a custom async adapter. The createJSONStorage helper takes a factory function — that's the () => localStorage — which delays access until after hydration, which matters a lot in Next.js server-side rendering where localStorage doesn't exist. Pair this with Empire UI components that depend on client-side state — like theme switching in our glassmorphism components — and you'll avoid the dreaded hydration mismatch flash.
Putting It All Together: Middleware Composition
Real-world stores usually need devtools and persist stacked together, and possibly Immer too. Zustand's middleware is composable — you wrap from outside in. The order matters: devtools should be outermost so it can observe all mutations, then persist, then immer:
// store/useAppStore.ts — full production setup
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { AuthSlice, createAuthSlice } from './slices/authSlice';
import { UISlice, createUISlice } from './slices/uiSlice';
import { CartSlice, createCartSlice } from './slices/cartSlice';
export type StoreState = AuthSlice & UISlice & CartSlice;
export const useAppStore = create<StoreState>()(
devtools(
persist(
immer((...a) => ({
...createAuthSlice(...a),
...createUISlice(...a),
...createCartSlice(...a),
})),
{
name: 'app-store',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
theme: state.theme,
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
version: 1,
}
),
{ name: 'AppStore', enabled: process.env.NODE_ENV !== 'production' }
)
);Why devtools outermost? Because persist adds its own internal mutations during rehydration, and you want those to show up in the DevTools timeline too. Putting devtools inside persist would hide the _hasHydrated events and make hydration bugs harder to trace.
In practice, you won't need all three middlewares on every store. Keep your small, ephemeral UI stores (modal open/closed, hover state) as bare create calls with no middleware at all — they're faster and simpler. Reserve the full stack for stores that own real application state that users expect to survive a refresh. The gradient generator on Empire UI, for example, persists the user's last-used gradient config so you don't lose your work on reload — exactly this pattern.
One last thing worth calling out: Zustand 5.0 dropped the shallow re-export from the main package. If you're upgrading from 4.x and you were doing import { shallow } from 'zustand', you'll need to switch to import { useShallow } from 'zustand/react/shallow'. That's the most common migration stumbling block, and it's not obvious from the error message.
FAQ
It's optional. For flat state shapes, the default set callback is totally fine. Immer becomes worth it once you're updating nested objects or arrays — the direct-mutation syntax cuts boilerplate by half. Install it only when the pain is real.
Yes, but you need to be careful with persist. Wrap localStorage access in createJSONStorage(() => localStorage) — the factory function defers access until the client is ready. For SSR-critical state, initialize your store with skipHydration: true and manually call useAppStore.persist.rehydrate() inside a useEffect.
Define your initial state as a separate const, then add a reset action that calls set(initialState). If you're using Immer, you still pass the plain object — not a mutation function — to set for a full reset: set(initialState, true) (the true replaces instead of merging).
Separate stores are independent — actions in one can't directly read or update the other without importing the store. Slices share the same store, so any action can call get() to read any slice's state. Use slices when your domains are interdependent (auth affecting cart, theme affecting UI), separate stores when they're truly isolated.