Skip to content
Dev Tools Intermediate Tutorial

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.

Lenn Voss
Lenn Voss
Cloud & Infrastructure Writer · Aug 28, 2026 · 6 min read
Manage React State with Zustand Slices, Middleware, and Persistence

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-vite installs today).
  • The Redux DevTools browser extension (Chrome, Firefox). Zustand's devtools middleware 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.
  • devtools wraps persist, not the other way round. The docs recommend devtools outermost because it changes set's signature and other middleware can lose that change.
  • partialize writes only items and theme to storage. user stays in memory, so a reload logs you out, and sidebarOpen resets. The version field is there so you can add a migrate function 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 localStorage by passing storage: createJSONStorage(() => sessionStorage) or an async engine; the persist reference covers migrate, merge, and onRehydrateStorage.
  • createJSONStorage doesn't validate what it reads back. For production, wrap the deserialized value in a schema check before trusting it.
  • Add immer for nested updates. Keep devtools outermost and add ['zustand/immer', never] to Middlewares; the advanced TypeScript guide lists every middleware's mutator tuple.
  • Hide noisy actions from DevTools with the actionsDenylist option on the devtools middleware.

Sources & further reading

  1. Slices Pattern — zustand.docs.pmnd.rs
  2. TypeScript Guide (middlewares, mutators, slices pattern) — zustand.docs.pmnd.rs
  3. persist middleware reference — zustand.docs.pmnd.rs
  4. devtools middleware reference — zustand.docs.pmnd.rs
  5. Migrating to v5 (stable selector outputs) — zustand.docs.pmnd.rs
  6. Getting Started — vite.dev
Lenn Voss
Written by
Lenn Voss · Cloud & Infrastructure Writer

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

Join the discussion

Sign in or create an account to comment and vote.

Theo Kallis @testing_theo · 9 hours ago

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.

Related Reading