| name | state-management |
| description | Client-side state management with Zustand v5 — global stores, selectors, useShallow, persist (localStorage), devtools, immer middleware, slices pattern. Use for shared UI state across components — filters, selections, sidebar/modal state, shopping cart, undo history, drag state, multi-step wizard state, anything read or written by more than one component. Do NOT use for server data (use data-fetching) or URL-synced state (use useSearchParams from react-router). Load whenever a component needs state that isn't local to it. |
State Management
Zustand v5 for client-side shared state. Already installed in the template (no npm install needed).
Why Zustand: zero boilerplate, no providers, ~1 KB, first-class TypeScript, selectors prevent unnecessary re-renders. For this template's app-builder workloads it's the right default; reach for data-fetching for server data and useSearchParams for URL-synced filters.
Quick start — TypeScript store
Use create<T>()() — double parentheses (see Issue #1 for why).
import { create } from "zustand"
interface BearStore {
bears: number
increase: (by: number) => void
}
const useBearStore = create<BearStore>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))
Use in components
Always select the specific slice you need — this is what stops unnecessary re-renders:
const bears = useBearStore((state) => state.bears)
const increase = useBearStore((state) => state.increase)
Core patterns
Counter store:
interface CounterStore { count: number; increment: () => void }
const useStore = create<CounterStore>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
Persistent store (survives page reloads via localStorage):
import { persist, createJSONStorage } from "zustand/middleware"
const useStore = create<UserPreferences>()(
persist(
(set) => ({
theme: "system",
setTheme: (theme) => set({ theme }),
}),
{
name: "user-preferences",
storage: createJSONStorage(() => localStorage),
},
),
)
Critical rules
Always:
- Use
create<T>()() (double parentheses) in TypeScript (Issue #1)
- Define separate interfaces for state and actions
- Use selector functions to extract specific state slices
- Use
set with updater functions for derived state: set((state) => ({ count: state.count + 1 }))
- Use unique
name values for each persist-middleware store
- Use
useShallow when selecting multiple values into one object
- Keep actions pure — no side effects beyond state updates
Never:
- Use
create<T>(...) (single parentheses) — Issue #1
- Create new objects in selectors:
useStore((state) => ({ a: state.a })) — Issue #3
- Mutate state directly:
set((state) => { state.count++; return state }) — always return a new object (or use immer)
- Reuse a persist
name across stores — causes data collisions
- Use Zustand for server data — use
data-fetching (TanStack Query) instead
- Export the store instance directly — always export the
useStore hook
Known issues this prevents
Issue #1: TypeScript double parentheses missing
Error: Type inference fails, StateCreator types break with middleware.
Why: The currying syntax create<T>()() is required for middleware to work with TypeScript inference.
const useStore = create<MyStore>((set) => ({ }))
const useStore = create<MyStore>()((set) => ({ }))
Rule: Always use create<T>()() in TypeScript, even without middleware (future-proof).
Issue #2: Persist middleware import error
Error: Attempted import error: 'createJSONStorage' is not exported from 'zustand/middleware'
Why: Wrong import path or v4↔v5 version mismatch.
import { create } from "zustand"
import { persist, createJSONStorage } from "zustand/middleware"
Confirm package.json has "zustand": "^5.0.10" or later.
Issue #3: Infinite render loop
Error: Maximum update depth exceeded. Browser freezes.
Why: Creating a new object reference inside the selector. Zustand sees a new reference on every render and re-renders, which creates another new reference, etc. Zustand v5 surfaces this immediately (v4 silently degraded).
import { useShallow } from "zustand/shallow"
const { bears, fishes } = useStore((state) => ({
bears: state.bears,
fishes: state.fishes,
}))
const bears = useStore((state) => state.bears)
const fishes = useStore((state) => state.fishes)
const { bears, fishes } = useStore(
useShallow((state) => ({ bears: state.bears, fishes: state.fishes }))
)
Issue #4: Slices pattern TypeScript complexity
Error: StateCreator types fail to infer when combining slices.
Why: Each slice needs explicit StateCreator annotations so the combined store type resolves.
import { create, StateCreator } from "zustand"
interface BearSlice { bears: number; addBear: () => void }
interface FishSlice { fishes: number; addFish: () => void }
const createBearSlice: StateCreator<
BearSlice & FishSlice,
[],
[],
BearSlice
> = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
})
const createFishSlice: StateCreator<
BearSlice & FishSlice,
[],
[],
FishSlice
> = (set) => ({
fishes: 0,
addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})
const useStore = create<BearSlice & FishSlice>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))
Issue #5: Persist middleware race condition (fixed v5.0.10+)
In Zustand ≤ v5.0.9, concurrent rehydration during persist initialization could leave the store in an inconsistent state. Fixed in v5.0.10 — no code changes needed, just stay on a current version. If you see flaky persisted state, check the installed version first.
Middleware
Persist (localStorage):
import { persist, createJSONStorage } from "zustand/middleware"
const useStore = create<MyStore>()(
persist(
(set) => ({
data: [],
addItem: (item) => set((state) => ({ data: [...state.data, item] })),
}),
{
name: "my-storage",
partialize: (state) => ({ data: state.data }),
storage: createJSONStorage(() => localStorage),
},
),
)
partialize lets you skip transient fields (open/closed flags, drag state) from the saved blob.
Devtools (Redux DevTools):
import { devtools } from "zustand/middleware"
const useStore = create<CounterStore>()(
devtools(
(set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 }), undefined, "increment"),
}),
{ name: "CounterStore" },
),
)
The third argument to set ("increment") becomes the action label in DevTools.
v4→v5 import note: in v4 you imported devtools from 'zustand/middleware/devtools'; in v5 the path is 'zustand/middleware'. Update if you see "Module not found: Can't resolve 'zustand/middleware/devtools'".
Combining middlewares (order matters — outer wraps inner):
const useStore = create<MyStore>()(
devtools(
persist(
(set) => ({ }),
{ name: "storage" },
),
{ name: "MyStore" },
),
)
Conventional order: devtools(persist(immer(stateCreator))).
Common patterns
Computed / derived values — compute in the selector, don't store:
const itemCount = useStore((state) => state.items.length)
const completedCount = useStore((state) => state.items.filter((i) => i.done).length)
Async actions:
const useDataStore = create<DataStore>()((set) => ({
data: null,
isLoading: false,
fetchData: async () => {
set({ isLoading: true })
const res = await fetch("/api/data")
set({ data: await res.json(), isLoading: false })
},
}))
For anything that hits /api/... prefer data-fetching (TanStack Query) — Zustand-as-fetcher gives up cache invalidation, retries, request deduplication, and signal cancellation.
Reset to initial state:
const initialState = { count: 0, name: "" }
const useStore = create<ResettableStore>()((set) => ({
...initialState,
reset: () => set(initialState),
}))
Selector with params (lookup):
const todo = useStore((state) => state.todos.find((t) => t.id === id))
If the lookup is expensive and the input is stable, wrap the selector in useCallback to keep its reference stable across renders.
Advanced
Vanilla store (use outside React):
import { createStore } from "zustand/vanilla"
const store = createStore<CounterStore>()((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))
const unsubscribe = store.subscribe((state) => console.log(state.count))
store.getState().increment()
Useful for wiring stores to non-React code (workers, listeners, services).
Custom middleware:
const logger: Logger = (f, name) => (set, get, store) => {
const loggedSet: typeof set = (...args) => {
set(...args)
console.log(`[${name}]`, get())
}
return f(loggedSet, get, store)
}
Immer middleware (mutable-style updates for deeply nested state):
import { immer } from "zustand/middleware/immer"
const useKanbanStore = create<KanbanStore>()(
immer((set) => ({
columns: { todo: { items: [] }, doing: { items: [] }, done: { items: [] } },
moveItem: (itemId, fromCol, toCol) => set((state) => {
const item = state.columns[fromCol].items.find((i) => i.id === itemId)
if (!item) return
state.columns[fromCol].items = state.columns[fromCol].items.filter((i) => i.id !== itemId)
state.columns[toCol].items.push(item)
}),
})),
)
immer is built in to Zustand v5 — no extra install. Keep the ()() pattern: create<T>()(immer(...)).
v5.0.3→v5.0.4 note: if immer stops working after upgrading, confirm the import path is zustand/middleware/immer (not a deeper path).
When to use what
| Need | Use |
|---|
| Local to one component | useState |
| Frequently changing value (mouse position, scroll) that doesn't need to re-render | useRef |
| Shared across 2+ components (UI flags, selections, modals, kanban state) | Zustand |
Server data from /api | data-fetching (TanStack Query) |
| Form state | react-hook-form (covered by ui skill) |
| Filters / pagination that should live in the URL | useSearchParams from react-router |
| Cross-tab synchronization of persisted state | Zustand persist + a storage event listener |
Common mistakes
- Destructuring the whole store —
const { x, y } = useStore() re-renders on any store change (distinct from the new-object-in-selector trap in Issue #3, which infinite-loops). Use useStore((s) => s.x) per value, or useShallow for a multi-value object.
For the ()() currying trap see Issue #1, infinite renders Issue #3, and server-data / mutation / persist-name rules in the Critical rules lists above.
Official documentation