Claude Code with Zustand: Stores, Selectors, SSR Patterns
Why Zustand needs a project-specific CLAUDE.md
Zustand is small enough that you can read its source in an afternoon. It exposes a create function, a hook returned from that function, and a handful of middleware. The API surface is tiny. The footguns are not.
Claude Code knows Zustand. It can write create<State>()(set => ({ ... })), register middleware, and produce selectors from memory. What it does not know without help is which slicing pattern your project uses, whether the persist middleware needs an SSR-safe storage adapter, how strict your selector discipline is, and which actions belong on the store versus in component code. Without those rules, you get stores that work for the first three components and then start producing infinite re-render loops, hydration mismatches, or memory leaks the moment a fourth component subscribes.
The failure modes are recognisable. A selector that returns { a, b } causes every subscribing component to re-render on every state change, because the object reference is new each time. A persist middleware that uses localStorage directly throws on the server during Next.js rendering. An action that calls set(state => state.items.push(item)) mutates the previous state in place and breaks every memoised selector reading items. A store created at module top level captures a stale closure when used inside a server component. Each of these compiles, each passes the lint, and each ships.
This guide covers the CLAUDE.md rules and store patterns that make Claude Code reliable for Zustand. If you are new to Claude Code, the Claude Code setup guide covers installation and authentication first. For broader React conventions, the Claude Code with React guide covers component patterns and rendering rules that pair with Zustand stores.
The Zustand CLAUDE.md template
The CLAUDE.md at your project root is read before every Claude Code session. For Zustand, it needs to answer: which version, where stores live, which slicing pattern, which middleware, how selectors are written, and how SSR is handled.
# Zustand project rules
## Stack
- Zustand: 4.5.x (Zustand 5 is opt-in, do not migrate without explicit instruction)
- TypeScript: 5.6.x with strict mode and noUncheckedIndexedAccess
- React: 18.3.x with concurrent rendering enabled
- Framework: Next.js 14.x App Router with Server Components
- Persistence: idb-keyval for IndexedDB, never localStorage directly in store code
- Devtools: zustand/middleware devtools, enabled only in development
## Project structure
- src/stores/: one file per store domain (auth.ts, cart.ts, ui.ts, etc.)
- src/stores/index.ts: re-exports public hooks and selectors
- src/stores/{name}/slices/: slice files when a store has more than 80 lines
- src/stores/{name}/selectors.ts: pre-built selectors with stable references
- Component code imports hooks and selectors, never the raw store
## Store rules
- Every store uses create<State>()(...) with explicit generics, never inferred
- Every store has a paired State type and an Actions type, combined as State & Actions
- Actions are defined inside the store, not as external functions taking the store
- One store per domain: do not create a global super-store with everything inside
- Stores never import from React components; the dependency is one-way
## Selector rules
- Components subscribe via hooks with a selector function: useStore(state => state.value)
- Selectors that return a single primitive or stable reference do not need equality
- Selectors that return an object or array MUST use the shallow equality helper
- Pre-built selectors live in selectors.ts and are referenced by name, not inlined
- NEVER write useStore(state => ({ a: state.a, b: state.b })) without shallow
## Middleware rules
- persist middleware MUST use a storage adapter that no-ops on the server
- devtools middleware is wrapped in a development-only conditional
- immer middleware is required for any state shape with nested objects or arrays
- Middleware order: devtools(persist(immer(creator))), outermost first
## SSR rules
- Stores used in Server Components must be created per-request, not at module top level
- The Next.js pattern is a StoreProvider that creates the store in a client component
- localStorage and sessionStorage access is gated behind typeof window checks
- Initial state from the server is hydrated via setState in a useEffect, never in render
## Hard rules
- NEVER call set() with a function that mutates state directly outside of immer middleware
- NEVER return new object/array references from selectors without shallow equality
- NEVER access localStorage or sessionStorage at module top level
- NEVER subscribe to the whole store: useStore() without a selector is forbidden
- NEVER create stores inside component render functions
Three rules in this template prevent the most common Claude Code failures.
The selector rule catches the single largest source of performance regressions in Zustand projects. A selector that returns state => ({ a: state.a, b: state.b }) produces a new object every time it runs. React compares the result with Object.is by default, sees a new reference, and re-renders the component, even if a and b are unchanged. Multiply that across ten components subscribing to similar slices and every state update triggers a cascade. The fix is the shallow equality function from zustand/shallow, which compares object properties by reference rather than the object itself.
The middleware order rule prevents subtle initialisation bugs. Middleware in Zustand wraps the store creator, and the order determines what each layer sees. Devtools needs to be outermost so it observes actions after immer has produced the next state. Persist needs to be inside devtools so devtools sees the persistence reload as a regular action. Immer needs to be innermost so it transforms the creator function before any other middleware reads from it. Get the order wrong and devtools shows the wrong actions, or persist captures the immer draft instead of the final state.
The SSR rule prevents hydration mismatches in Next.js App Router projects. A store created at module top level is shared across all requests on the server, which means user A's cart can leak into user B's response. The Next.js pattern requires a client-side StoreProvider that creates the store inside React, so each render gets its own instance.
For broader CLAUDE.md structure, CLAUDE.md explained covers how the file is read across project types.
Store creation and slicing patterns
Once CLAUDE.md is in place, give Claude a real store file to extend rather than generate from scratch. A reference file lets Claude pattern-match against your conventions instead of generic Zustand tutorials.
// src/stores/cart.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
type CartItem = {
id: string
productId: string
quantity: number
priceInCents: number
}
type CartState = {
items: CartItem[]
discountCode: string | null
isLoading: boolean
}
type CartActions = {
addItem: (item: Omit<CartItem, 'id'>) => void
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
applyDiscount: (code: string) => void
clear: () => void
}
type CartStore = CartState & CartActions
const initialState: CartState = {
items: [],
discountCode: null,
isLoading: false,
}
export const useCartStore = create<CartStore>()(
devtools(
persist(
immer((set) => ({
...initialState,
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.productId === item.productId)
if (existing) {
existing.quantity += item.quantity
} else {
state.items.push({ ...item, id: crypto.randomUUID() })
}
}),
removeItem: (id) =>
set((state) => {
state.items = state.items.filter((i) => i.id !== id)
}),
updateQuantity: (id, quantity) =>
set((state) => {
const item = state.items.find((i) => i.id === id)
if (item) item.quantity = Math.max(1, quantity)
}),
applyDiscount: (code) =>
set((state) => {
state.discountCode = code
}),
clear: () => set(() => ({ ...initialState })),
})),
{
name: 'cart-storage',
partialize: (state) => ({
items: state.items,
discountCode: state.discountCode,
}),
},
),
{ name: 'cart-store' },
),
)
A few elements are worth highlighting because Claude reproduces them once they are in your repo.
The split between CartState and CartActions is deliberate. State is the data, actions are the operations. Keeping them in separate types makes it easy to derive selectors that target only state, and to write tests that mock actions without mocking the whole store. Without this split, Claude often produces a single type that mixes both, which makes selector typing harder later.
The initialState constant is extracted because the clear action needs it. Without a named initial state, the reset action becomes a sprawl of set({ items: [], discountCode: null, isLoading: false }) calls that drift from the original shape over time. With the constant, the reset is one line that stays accurate as the state grows.
The partialize option in persist excludes isLoading from the persisted state. Loading flags should never persist across reloads, because they capture a transient state that no longer applies after the page is fresh. Other transient fields, like error messages or pending request IDs, follow the same rule.
For stores that grow past 80 lines, split into slices. The slice pattern keeps related actions together while letting Claude work on one slice at a time:
// src/stores/auth/slices/session.ts
import type { StateCreator } from 'zustand'
export type SessionSlice = {
userId: string | null
accessToken: string | null
expiresAt: number | null
login: (userId: string, token: string, expiresAt: number) => void
logout: () => void
}
export const createSessionSlice: StateCreator<
SessionSlice,
[['zustand/immer', never]],
[],
SessionSlice
> = (set) => ({
userId: null,
accessToken: null,
expiresAt: null,
login: (userId, token, expiresAt) =>
set((state) => {
state.userId = userId
state.accessToken = token
state.expiresAt = expiresAt
}),
logout: () =>
set((state) => {
state.userId = null
state.accessToken = null
state.expiresAt = null
}),
})
// src/stores/auth/slices/preferences.ts
import type { StateCreator } from 'zustand'
export type PreferencesSlice = {
theme: 'light' | 'dark' | 'system'
locale: string
setTheme: (theme: 'light' | 'dark' | 'system') => void
setLocale: (locale: string) => void
}
export const createPreferencesSlice: StateCreator<
PreferencesSlice,
[['zustand/immer', never]],
[],
PreferencesSlice
> = (set) => ({
theme: 'system',
locale: 'en-GB',
setTheme: (theme) =>
set((state) => {
state.theme = theme
}),
setLocale: (locale) =>
set((state) => {
state.locale = locale
}),
})
// src/stores/auth/index.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
import { createSessionSlice, type SessionSlice } from './slices/session'
import { createPreferencesSlice, type PreferencesSlice } from './slices/preferences'
type AuthStore = SessionSlice & PreferencesSlice
export const useAuthStore = create<AuthStore>()(
devtools(
persist(
immer((...args) => ({
...createSessionSlice(...args),
...createPreferencesSlice(...args),
})),
{
name: 'auth-storage',
partialize: (state) => ({
theme: state.theme,
locale: state.locale,
}),
},
),
{ name: 'auth-store' },
),
)
The StateCreator type signature looks intimidating but it is mechanical. The four generics describe the slice state, the middleware in effect, the middleware that will be applied later, and the resulting type. Claude generates these correctly when the pattern is present in the codebase, and gets them wrong about half the time when generating from scratch. The fix is a reference slice in the repo and a CLAUDE.md note pointing to it.
For broader TypeScript conventions, the Claude Code TypeScript guide covers tsconfig strictness and type generation patterns that pair with Zustand stores.
Middleware: persist, devtools, immer
The three middleware that account for almost all production Zustand usage are persist, devtools, and immer. Each has a specific role and a specific failure mode that Claude needs to know about.
Persist saves the store to a storage backend and rehydrates it on load. The default backend is localStorage, which works in the browser but throws on the server. For Next.js App Router projects, the persist middleware needs a storage adapter that no-ops during server rendering and switches to the real storage on the client:
// src/stores/lib/storage.ts
import type { PersistStorage, StorageValue } from 'zustand/middleware'
const noopStorage: PersistStorage<unknown> = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
}
export function createJSONStorage<T>(): PersistStorage<T> {
if (typeof window === 'undefined') {
return noopStorage as PersistStorage<T>
}
return {
getItem: (name) => {
const raw = window.localStorage.getItem(name)
if (raw === null) return null
try {
return JSON.parse(raw) as StorageValue<T>
} catch {
return null
}
},
setItem: (name, value) => {
try {
window.localStorage.setItem(name, JSON.stringify(value))
} catch {
// quota exceeded or storage disabled; ignore
}
},
removeItem: (name) => {
window.localStorage.removeItem(name)
},
}
}
This adapter returns no-ops on the server so persist never touches window during SSR, and wraps the real storage in try/catch so a full quota or disabled storage does not crash the app. The JSON parse is also wrapped in try/catch because corrupted entries from older app versions are a real possibility.
For larger persisted state, IndexedDB via idb-keyval is a better choice than localStorage. The pattern is similar but uses async getItem and setItem, which persist supports natively in version 4.
Devtools wires the store into the Redux DevTools browser extension. The middleware accepts a name and serialises every action. In development, this gives you a time-travel debugger for free. In production, it adds overhead and exposes internal state to anyone with the extension installed:
import { devtools } from 'zustand/middleware'
const isDev = process.env.NODE_ENV === 'development'
export const useUIStore = create<UIStore>()(
devtools(
(set) => ({
// store implementation
}),
{
name: 'ui-store',
enabled: isDev,
},
),
)
The enabled flag is the right way to gate devtools. Wrapping the middleware in a conditional is also possible but produces awkward types. The flag tells devtools to register actions only when the condition is met.
Immer lets you write actions that look like mutations but produce immutable next states. Without immer, every nested update becomes a chain of spreads:
// Without immer
set((state) => ({
...state,
user: {
...state.user,
preferences: {
...state.user.preferences,
theme: 'dark',
},
},
}))
// With immer
set((state) => {
state.user.preferences.theme = 'dark'
})
The immer version is shorter and harder to break. Claude defaults to the spread version without explicit guidance, because spread is what most React tutorials use. The CLAUDE.md rule "immer middleware is required for nested state" pushes Claude toward the mutation-style syntax, which Immer translates to immutable updates under the hood.
For broader Next.js patterns, the Claude Code with Next.js guide covers App Router conventions that pair with Zustand stores.
Selectors and preventing re-renders
Selectors are the single most consequential decision in any Zustand-heavy codebase. The wrong selector produces correct behaviour with terrible performance, which is the worst kind of bug because it hides until the app has real users.
The default Zustand subscription compares the selector result to the previous result with Object.is. Primitives, strings, and references that did not change pass the check. Fresh objects and arrays fail the check, even when their contents are identical. The selector state => ({ count: state.count, name: state.name }) produces a new object every time, so the equality check always fails, and the component re-renders on every state update regardless of whether count or name changed.
The fix is the shallow equality function from zustand/shallow. It compares object properties or array elements by reference rather than the wrapper itself:
import { useShallow } from 'zustand/react/shallow'
// Wrong: re-renders on every state update
function Header() {
const { user, theme } = useAuthStore((state) => ({
user: state.user,
theme: state.theme,
}))
return <div>{user.name} ({theme})</div>
}
// Right: re-renders only when user or theme changes
function Header() {
const { user, theme } = useAuthStore(
useShallow((state) => ({
user: state.user,
theme: state.theme,
})),
)
return <div>{user.name} ({theme})</div>
}
// Also right: subscribe to one primitive at a time
function Header() {
const user = useAuthStore((state) => state.user)
const theme = useAuthStore((state) => state.theme)
return <div>{user.name} ({theme})</div>
}
The single-primitive pattern is the easiest to reason about and the hardest for Claude to get wrong. Each useStore call subscribes to one field, the equality check is trivial, and the component re-renders only when that field changes. The downside is verbosity when a component needs many fields. The useShallow pattern is the right balance: bundle related fields, shallow-compare, single subscription.
Pre-built selectors are the next level. Defining selectors once in a dedicated file gives you a stable reference and lets Claude pattern-match against existing patterns rather than inventing new ones:
// src/stores/cart/selectors.ts
import { useShallow } from 'zustand/react/shallow'
import { useCartStore } from './index'
export const useCartItemCount = () =>
useCartStore((state) => state.items.reduce((sum, i) => sum + i.quantity, 0))
export const useCartSubtotal = () =>
useCartStore((state) =>
state.items.reduce((sum, i) => sum + i.priceInCents * i.quantity, 0),
)
export const useCartSummary = () =>
useCartStore(
useShallow((state) => ({
itemCount: state.items.reduce((sum, i) => sum + i.quantity, 0),
subtotal: state.items.reduce(
(sum, i) => sum + i.priceInCents * i.quantity,
0,
),
discountCode: state.discountCode,
})),
)
export const useCartActions = () =>
useCartStore(
useShallow((state) => ({
addItem: state.addItem,
removeItem: state.removeItem,
updateQuantity: state.updateQuantity,
clear: state.clear,
})),
)
The actions selector is particularly useful. Action functions are stable references created once when the store is initialised, so a shallow comparison succeeds on every render. Components can call useCartActions() once and destructure all the actions without triggering re-renders on state changes, because the selector returns the same object whenever the action references are unchanged.
Add a selector section to CLAUDE.md to nudge Claude toward pre-built selectors:
## Selector files
- Each store has a paired selectors.ts file in the same directory
- Selectors are hooks: useCartItemCount, useUserDisplayName, etc.
- Computed values live in selectors, not in components
- Components import named selectors, never write inline shallow selectors
- New computed values are added to selectors.ts first, then imported
For broader debugging strategies, the Claude Code debugging guide covers tooling for spotting unnecessary re-renders and other React performance issues.
SSR with Next.js App Router
Server-side rendering with Zustand is the area where Claude makes the most mistakes without explicit guidance. The model is simple: stores created at module top level live for the lifetime of the process, which on a server is shared across all requests. User A modifies the cart, user B's next request renders A's cart in the initial HTML, hydration mismatches, and the bug looks like a flash of incorrect content followed by a snap to the right state.
The fix is the StoreProvider pattern. The store is created inside a client component that lives once per render tree, and React context distributes it to children:
// src/stores/cart/provider.tsx
'use client'
import { createContext, useContext, useRef, type ReactNode } from 'react'
import { useStore, type StoreApi } from 'zustand'
import { createCartStore, type CartStore } from './store'
const CartStoreContext = createContext<StoreApi<CartStore> | null>(null)
export function CartStoreProvider({
children,
initialState,
}: {
children: ReactNode
initialState?: Partial<CartStore>
}) {
const storeRef = useRef<StoreApi<CartStore>>()
if (!storeRef.current) {
storeRef.current = createCartStore(initialState)
}
return (
<CartStoreContext.Provider value={storeRef.current}>
{children}
</CartStoreContext.Provider>
)
}
export function useCartStore<T>(selector: (state: CartStore) => T): T {
const store = useContext(CartStoreContext)
if (!store) {
throw new Error('useCartStore must be used within CartStoreProvider')
}
return useStore(store, selector)
}
The companion store factory exposes a creator instead of a singleton:
// src/stores/cart/store.ts
import { createStore } from 'zustand/vanilla'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
import { createJSONStorage } from '../lib/storage'
export type CartStore = {
// state and actions as before
}
export function createCartStore(initialState?: Partial<CartStore>) {
return createStore<CartStore>()(
devtools(
persist(
immer((set) => ({
// ...store implementation
})),
{
name: 'cart-storage',
storage: createJSONStorage<CartStore>(),
},
),
{ name: 'cart-store' },
),
)
}
createStore from zustand/vanilla returns a store API without a hook. The provider creates the store inside a ref, which means each provider instance gets its own store. The useStore hook from zustand is then used inside the consumer hook to subscribe to the contextual store rather than a module-level singleton.
The pattern adds boilerplate compared to a module-level store. The payoff is correctness: server renders never share state across requests, hydration matches between server and client, and the persist middleware safely runs only on the client because the provider only mounts in client components.
For initial state from the server, pass it through the provider:
// src/app/cart/page.tsx
import { CartStoreProvider } from '@/stores/cart/provider'
import { getCartForUser } from '@/lib/cart'
import { CartView } from './cart-view'
export default async function CartPage() {
const initialCart = await getCartForUser()
return (
<CartStoreProvider initialState={initialCart}>
<CartView />
</CartStoreProvider>
)
}
The server component fetches the cart, passes it to the provider as a prop, and the provider seeds the store on creation. The client never refetches the cart on hydration because the initial state is already correct.
For broader testing patterns around Zustand stores, the Claude Code testing guide covers store mocking and integration testing patterns.
Zustand versus Redux Toolkit and Jotai
The choice between Zustand, Redux Toolkit, and Jotai is genuine and depends on the shape of your state. Zustand is the right default for small to medium React applications with shared global state that updates from a few sources. The API is minimal, the bundle cost is around 1KB, the learning curve is short, and TypeScript inference is excellent. The trade-off is that Zustand has fewer guardrails: a senior developer can use it well, a junior developer can produce stores that work but re-render constantly. Redux Toolkit is the right choice when you have a large team, complex async flows, or strong opinions about action logs and time-travel debugging out of the box. RTK Query bundles caching and request deduplication, which Zustand leaves to libraries like TanStack Query. The bundle cost is higher and the boilerplate is heavier, but the structure is harder to misuse. Jotai is the right choice when your state is naturally atomic: many small independent pieces of state that components combine into derived values. The atom model is conceptually different from store-based state managers and rewards projects that lean into it fully. For mostly-shared state with a few derived values, Zustand wins on simplicity. For genuinely atomic state with many fine-grained subscriptions, Jotai wins. For projects already on Redux or with strong RTK Query needs, RTK Query plus RTK is usually right.
For Claude Code specifically, Zustand's small surface makes it the easiest to constrain via CLAUDE.md. The rules in this guide cover almost the entire decision space. Redux Toolkit needs longer CLAUDE.md files because of slice patterns, selectors, RTK Query, async thunks, and middleware composition. Jotai needs different rules entirely because the atomic model changes how state is organised. Pick the library that matches your team and project shape, then write the CLAUDE.md to match.
For broader TypeScript patterns that interact with state management, the Claude Code with tRPC guide covers server-client type sharing that pairs with Zustand stores.
Hard rules summary
The patterns above reduce to a list of mandatory rules that belong at the top of every Zustand CLAUDE.md.
- Every store has paired State and Actions types, combined as
State & Actions. - Stores are created with explicit generics:
create<Store>()(...), never inferred. - Selectors that return objects or arrays use the
useShallowhelper. - Pre-built selectors live in
selectors.tsand are referenced by name. - Components never subscribe to the whole store:
useStore()without a selector is forbidden. - Persist middleware uses an SSR-safe storage adapter, never raw
localStorage. - Devtools middleware is enabled only in development via the
enabledflag. - Immer middleware is required for any state shape with nested objects or arrays.
- Middleware order is
devtools(persist(immer(creator))), outermost first. - Stores used in Next.js App Router are created per-request via a StoreProvider.
localStorageandsessionStorageaccess is gated behindtypeof windowchecks.- Initial state from the server hydrates via the provider prop, never via setState in render.
- Action functions are stable references; bundle them with
useShallowfor components that call many actions. - Transient fields (loading, error) are excluded from persist via
partialize.
These rules prevent the most common Zustand failures in Claude Code sessions. They produce stores that scale past three components without performance regressions, selectors that re-render only when their dependencies change, persistence that works in SSR environments without hydration mismatches, and a project structure where each store has a clear domain and a stable public API.
State management is the area of a React application where small mistakes compound fastest. A bad selector ships, twenty components subscribe to it, and the performance debt accumulates until a Lighthouse report flags it months later. With the rules above in CLAUDE.md, Claude Code generates stores that match your project conventions on the first pass, instead of producing stores that work for the first feature and degrade with every addition. Claudify includes a Zustand-specific CLAUDE.md template pre-configured for the patterns above. For the broader role of CLAUDE.md across project types, the Claude Code best practices guide covers how to structure project rules for any stack.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify