Manage React State with Zustand Slices, Middleware, and Persistence
Split a React app's state into typed Zustand slices with named DevTools actions and a cart that survives reloads.
What you'll build
You'll take a small dashboard app whose state is smeared across useState and prop chains and rebuild it as one Zustand store made of three typed slices (auth, cart, ui), with named actions in Redux DevTools and a cart and theme that survive a page reload. No reducers, no action creators, no Provider.
Prerequisites
Verified on macOS 15 with these versions; Linux and Windows work the same.
- Node.js 20.19+ or 22.12+ (the Vite floor). Tested with 20.19.6 and npm 10.8.
- Zustand 5.0.15, React 19.2, Vite 8.2, TypeScript 6.0 (what
create-viteinstalls today). - The Redux DevTools browser extension (Chrome, Firefox). Zustand's
devtoolsmiddleware talks to it directly.
If you're refactoring an existing app, skip step 1 and drop the src/store files into your tree. Everything else applies unchanged.
1. Scaffold the app and install Zustand
npm create vite@latest shop-dashboard -- --template react-ts
cd shop-dashboard
npm install
npm install zustand
The react-ts template ships with verbatimModuleSyntax on, which matters in the next step: every type you import from Zustand has to use import type.
2. Write the auth slice
A slice is a function with the same shape as what you'd hand to create(), typed with StateCreator. The four generics are: the full store type, the middleware list, an empty list (middlewares this slice adds, always []), and the slice's own type. Typing against the full AppState is what lets one slice call another's actions.
Create src/store/authSlice.ts:
import type { StateCreator } from 'zustand'
import type { AppState, Middlewares } from './index'
export interface AuthSlice {
user: string | null
login: (name: string) => void
logout: () => void
}
export const createAuthSlice: StateCreator<AppState, Middlewares, [], AuthSlice> = (set, get) => ({
user: null,
login: (name) => set({ user: name }, undefined, 'auth/login'),
logout: () => {
set({ user: null }, undefined, 'auth/logout')
get().clearCart() // cross-slice call: a logout must not leave the old user's cart behind
},
})
The third argument to set is the action name DevTools displays. The second (undefined) is the replace flag; leave it alone so set keeps merging.
AppState and Middlewares come from index.ts, which in turn imports this file. That cycle is fine because both imports are type-only and get erased at build time.
3. Write the cart and ui slices
src/store/cartSlice.ts. This one passes an object as the action name so DevTools shows the payload:
import type { StateCreator } from 'zustand'
import type { AppState, Middlewares } from './index'
export interface CartItem {
sku: string
qty: number
}
export interface CartSlice {
items: CartItem[]
addItem: (sku: string) => void
clearCart: () => void
}
export const createCartSlice: StateCreator<AppState, Middlewares, [], CartSlice> = (set) => ({
items: [],
addItem: (sku) =>
set(
(state) => {
const existing = state.items.find((i) => i.sku === sku)
const items = existing
? state.items.map((i) => (i.sku === sku ? { ...i, qty: i.qty + 1 } : i))
: [...state.items, { sku, qty: 1 }]
return { items }
},
undefined,
{ type: 'cart/addItem', sku },
),
clearCart: () => set({ items: [] }, undefined, 'cart/clearCart'),
})
src/store/uiSlice.ts:
import type { StateCreator } from 'zustand'
import type { AppState, Middlewares } from './index'
export interface UiSlice {
theme: 'light' | 'dark'
sidebarOpen: boolean
toggleTheme: () => void
toggleSidebar: () => void
}
export const createUiSlice: StateCreator<AppState, Middlewares, [], UiSlice> = (set) => ({
theme: 'light',
sidebarOpen: true,
toggleTheme: () =>
set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' }), undefined, 'ui/toggleTheme'),
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen }), undefined, 'ui/toggleSidebar'),
})
4. Combine the slices under persist and devtools
Middleware goes on the combined store only. The Zustand docs are explicit that wrapping individual slices causes problems. Create src/store/index.ts:
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { createAuthSlice, type AuthSlice } from './authSlice'
import { createCartSlice, type CartSlice } from './cartSlice'
import { createUiSlice, type UiSlice } from './uiSlice'
export type AppState = AuthSlice & CartSlice & UiSlice
// Outer-to-inner order of the middleware stack below. Every slice's StateCreator
// needs this list so `set` gets devtools' third (action name) argument.
export type Middlewares = [['zustand/devtools', never], ['zustand/persist', unknown]]
export const useAppStore = create<AppState>()(
devtools(
persist(
(...a) => ({
...createAuthSlice(...a),
...createCartSlice(...a),
...createUiSlice(...a),
}),
{
name: 'shop-dashboard', // localStorage key
version: 1,
partialize: (state) => ({ items: state.items, theme: state.theme }),
},
),
{ name: 'ShopDashboard' },
),
)
Three details here do real work:
create<AppState>()(...)is called twice on purpose. The empty first call is the TypeScript workaround that lets middleware types infer correctly.devtoolswrapspersist, not the other way round. The docs recommenddevtoolsoutermost because it changesset's signature and other middleware can lose that change.partializewrites onlyitemsandthemeto storage.userstays in memory, so a reload logs you out, andsidebarOpenresets. Theversionfield is there so you can add amigratefunction later without breaking existing users' saved carts.
5. Read the store from a component
Replace src/App.tsx. Single-value selectors are fine as-is; anything that returns a fresh object needs useShallow, or Zustand 5 re-renders forever (see Troubleshooting).
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from './store'
export default function App() {
const user = useAppStore((s) => s.user)
const { items, theme, sidebarOpen } = useAppStore(
useShallow((s) => ({ items: s.items, theme: s.theme, sidebarOpen: s.sidebarOpen })),
)
const { login, logout, addItem, toggleTheme, toggleSidebar } = useAppStore(
useShallow((s) => ({
login: s.login,
logout: s.logout,
addItem: s.addItem,
toggleTheme: s.toggleTheme,
toggleSidebar: s.toggleSidebar,
})),
)
const count = items.reduce((n, i) => n + i.qty, 0)
return (
<main data-theme={theme} style={{ fontFamily: 'sans-serif', padding: 24 }}>
<h1>Shop dashboard</h1>
<p>
User: <strong>{user ?? 'anonymous'}</strong> · Theme: {theme} · Sidebar:{' '}
{sidebarOpen ? 'open' : 'closed'}
</p>
<p>
Cart: {count} item{count === 1 ? '' : 's'}
</p>
<button onClick={() => (user ? logout() : login('ada'))}>{user ? 'Log out' : 'Log in'}</button>{' '}
<button onClick={() => addItem('sku-42')}>Add sku-42</button>{' '}
<button onClick={toggleTheme}>Toggle theme</button>{' '}
<button onClick={toggleSidebar}>Toggle sidebar</button>
</main>
)
}
Then run it:
npm run dev
Verify it works
npm run build should finish with tsc -b silent and Vite printing ✓ built. In the browser at http://localhost:5173/, click Log in, Add sku-42 twice, Toggle theme, Toggle sidebar. The page reads:
User: ada · Theme: dark · Sidebar: closed
Cart: 2 items
Open DevTools > Application > Local Storage and the shop-dashboard key holds exactly this, with no user and no sidebarOpen:
{"state":{"items":[{"sku":"sku-42","qty":2}],"theme":"dark"},"version":1}
Reload. Cart and theme come back; user is anonymous and the sidebar is open again. Log in, then log out: the stored value drops to "items":[], proving the auth slice reached into the cart slice.
In the Redux DevTools panel, pick the ShopDashboard instance. The action list reads auth/login, cart/addItem (expand it to see sku: "sku-42"), ui/toggleTheme, and so on. Click any entry to time-travel the UI to that point.
Troubleshooting
error TS2554: Expected 1-2 arguments, but got 3. A slice is typed StateCreator<AppState, [], [], AuthSlice> and calls set with an action name. Without the Middlewares list in the second generic, TypeScript doesn't know devtools added the third parameter. Use StateCreator<AppState, Middlewares, [], AuthSlice>.
error TS1484: 'StateCreator' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled. The Zustand docs write import { create, StateCreator } from 'zustand', which the Vite template rejects. Split it: import { create } from 'zustand' and import type { StateCreator } from 'zustand'.
Uncaught Error: Maximum update depth exceeded. (The page usually freezes before the message appears.) A selector returns a new object or array on every call, and Zustand 5 compares with Object.is, so each render triggers another. Wrap that selector in useShallow from zustand/react/shallow, or split it into one selector per value.
Every action in Redux DevTools is labeled anonymous. You're calling set without the third argument. Pass a string like 'cart/addItem', or set anonymousActionType in the devtools options as a fallback label.
Next steps
- Persist somewhere other than
localStorageby passingstorage: createJSONStorage(() => sessionStorage)or an async engine; the persist reference coversmigrate,merge, andonRehydrateStorage. createJSONStoragedoesn't validate what it reads back. For production, wrap the deserialized value in a schema check before trusting it.- Add
immerfor nested updates. Keepdevtoolsoutermost and add['zustand/immer', never]toMiddlewares; the advanced TypeScript guide lists every middleware's mutator tuple. - Hide noisy actions from DevTools with the
actionsDenylistoption on the devtools middleware.
Sources & further reading
- Slices Pattern — zustand.docs.pmnd.rs
- TypeScript Guide (middlewares, mutators, slices pattern) — zustand.docs.pmnd.rs
- persist middleware reference — zustand.docs.pmnd.rs
- devtools middleware reference — zustand.docs.pmnd.rs
- Migrating to v5 (stable selector outputs) — zustand.docs.pmnd.rs
- Getting Started — vite.dev
Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.
Discussion 1
skipping the persistence layer without showing test coverage for rehydration is a red flag though. localStorage can fail silently, and i'd want to see tests proving the cart actually survives a reload *and* handles corrupted stored state gracefully. zustand's persistence is convenient but persistence bugs are brutal in production.