Zustand State Management With Claude Code
Design Zustand stores with Claude Code: selectors, persist partialization, async actions, tests, and review prompts.
Decide the State Boundary First
Zustand is a small React state-management library. In practical terms, state management means deciding which values should be shared across screens, which actions are allowed to update them, and which values should stay local to a component. Claude Code can generate a Zustand store quickly, but it needs a clear boundary. Without that boundary, temporary form text, authentication details, API responses, toast messages, and cart state can all end up in the same global store.
This guide uses Claude Code as a state-design assistant, not just a code generator. The examples cover five common use cases: admin-dashboard filters, a shopping cart, authentication UI state, modal/toast state, and optimistic updates. You will get copyable TypeScript for a store, selectors, persist partialization, an async action, Vitest tests, and a review prompt that asks Claude to be critical about the design.
The technical baseline is the Zustand official introduction, the persist middleware reference, the useShallow guide, and the official testing guidance. For server-state boundaries, read the related TanStack Query guide. For smaller atom-style state, compare the Jotai atoms article.
What Belongs in Zustand
A useful rule is simple: put a value in Zustand only when several distant components need it, when the update logic should be tested in one place, or when URL state alone is not enough. Do not put every server response into a Zustand store. Product lists, user tables, and search results usually need caching, refetching, stale-time rules, and error recovery, which are better handled by a server-state library.
| Use case | Keep in Zustand | Keep out | Claude Code instruction |
|---|---|---|---|
| Admin filters | keyword, status, page, pageSize | full API response | Separate URL-sync values from UI-only values |
| Cart | SKU, quantity, display price | payment session, inventory truth | Persist only low-risk cart fields |
| Auth UI | login dialog open state, checking indicator | access token, email, address | Never store PII or secrets |
| Modal/toast | activeModal, short toast queue | audit logs, long errors | Store only what the UI must render |
| Optimistic update | requestId, previous UI snapshot | final server truth | Define rollback and conflict rules |
Masa’s practical mistake on an admin screen was treating “use Zustand” as the goal. The first version stored the search input, URL query, fetched table rows, row selection, and toast state together. After a route change, stale filters and selected rows still affected the next view. The fix was not a clever middleware; it was a state inventory that asked which values were safe to keep globally.
flowchart LR
UI[React components] --> Selectors[Selectors and useShallow]
Selectors --> Store[Zustand store]
Store --> Actions[Actions]
Actions --> API[API and server state]
Store --> Persist[persist partialize]
Persist --> Storage[localStorage safe fields]
TypeScript Store
Start with a complete but narrow UI store. This example includes admin filters, cart state, auth UI state, modal state, and toasts. In a larger codebase you can split the file, but asking Claude Code for one complete working slice first makes review easier.
import { create, type StateCreator } from "zustand";
export type OrderStatus = "all" | "paid" | "refunded" | "failed";
export type AuthUiStatus = "anonymous" | "checking" | "signedIn";
export type ModalId = "invite-user" | "cart-drawer" | "delete-order" | null;
export interface AdminFilters {
keyword: string;
status: OrderStatus;
page: number;
pageSize: number;
}
export interface CartLine {
id: string;
name: string;
price: number;
quantity: number;
}
export interface AuthUi {
status: AuthUiStatus;
loginDialogOpen: boolean;
}
export interface Toast {
id: string;
kind: "success" | "error" | "info";
message: string;
}
export interface CommerceUiState {
filters: AdminFilters;
cart: CartLine[];
auth: AuthUi;
activeModal: ModalId;
toasts: Toast[];
setFilters: (patch: Partial<AdminFilters>) => void;
resetFilters: () => void;
addToCart: (line: Omit<CartLine, "quantity">) => void;
removeFromCart: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
setAuthStatus: (status: AuthUiStatus) => void;
setLoginDialogOpen: (open: boolean) => void;
openModal: (modal: Exclude<ModalId, null>) => void;
closeModal: () => void;
pushToast: (toast: Omit<Toast, "id">) => void;
dismissToast: (id: string) => void;
checkoutTotal: () => number;
}
export const initialFilters: AdminFilters = {
keyword: "",
status: "all",
page: 1,
pageSize: 25,
};
const createToastId = () =>
globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
export const createCommerceUiSlice: StateCreator<CommerceUiState> = (set, get) => ({
filters: initialFilters,
cart: [],
auth: { status: "anonymous", loginDialogOpen: false },
activeModal: null,
toasts: [],
setFilters: (patch) =>
set((state) => ({
filters: {
...state.filters,
...patch,
page: patch.page ?? 1,
},
})),
resetFilters: () => set({ filters: initialFilters }),
addToCart: (line) =>
set((state) => {
const current = state.cart.find((item) => item.id === line.id);
if (!current) {
return { cart: [...state.cart, { ...line, quantity: 1 }] };
}
return {
cart: state.cart.map((item) =>
item.id === line.id ? { ...item, quantity: item.quantity + 1 } : item,
),
};
}),
removeFromCart: (id) =>
set((state) => ({
cart: state.cart.filter((item) => item.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
cart: state.cart
.map((item) =>
item.id === id ? { ...item, quantity: Math.max(0, quantity) } : item,
)
.filter((item) => item.quantity > 0),
})),
setAuthStatus: (status) =>
set((state) => ({
auth: { ...state.auth, status },
})),
setLoginDialogOpen: (open) =>
set((state) => ({
auth: { ...state.auth, loginDialogOpen: open },
})),
openModal: (modal) => set({ activeModal: modal }),
closeModal: () => set({ activeModal: null }),
pushToast: (toast) =>
set((state) => ({
toasts: [...state.toasts, { ...toast, id: createToastId() }],
})),
dismissToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
})),
checkoutTotal: () =>
get().cart.reduce((total, item) => total + item.price * item.quantity, 0),
});
export const useCommerceUiStore = create<CommerceUiState>()(createCommerceUiSlice);
The important design choice is that the store holds authentication UI state, not authentication secrets. Access tokens, email addresses, shipping addresses, and legal names do not belong in this UI store. If you ask Claude Code to add persist, explicitly say that PII means personally identifiable information and must never be saved.
Selectors and Rendering Discipline
Zustand hooks are easy to call, but that ease can hide performance problems. A component that calls useCommerceUiStore() subscribes to the whole store. Then a toast update can re-render a cart badge, and a filter change can re-render an unrelated modal. A selector is the function that chooses only the part of the store a component needs.
import { useShallow } from "zustand/react/shallow";
import {
useCommerceUiStore,
type CommerceUiState,
} from "./commerce-ui-store";
export const selectCartCount = (state: CommerceUiState) =>
state.cart.reduce((sum, item) => sum + item.quantity, 0);
export const selectCartTotal = (state: CommerceUiState) => state.checkoutTotal();
export function CartBadge() {
const count = useCommerceUiStore(selectCartCount);
return <button type="button">Cart ({count})</button>;
}
export function AdminFilterSummary() {
const { filters, setFilters, resetFilters } = useCommerceUiStore(
useShallow((state) => ({
filters: state.filters,
setFilters: state.setFilters,
resetFilters: state.resetFilters,
})),
);
return (
<form>
<input
value={filters.keyword}
placeholder="Search orders"
onChange={(event) => setFilters({ keyword: event.currentTarget.value })}
/>
<select
value={filters.status}
onChange={(event) =>
setFilters({ status: event.currentTarget.value as CommerceUiState["filters"]["status"] })
}
>
<option value="all">All</option>
<option value="paid">Paid</option>
<option value="refunded">Refunded</option>
<option value="failed">Failed</option>
</select>
<output>Page {filters.page}</output>
<button type="button" onClick={resetFilters}>
Reset
</button>
</form>
);
}
When you ask Claude Code to review the selectors, make the requirement explicit: no component should subscribe to the full store unless it is a debugging component. If a selector returns an object, use useShallow or split it into primitive subscriptions. This is the difference between a small demo that feels fine and a real dashboard that stays responsive when tables, filters, sidebars, and notifications all update.
Persist Only Safe Fields
The persist middleware is useful for state that should survive reloads. It is also a common privacy bug. Cart lines and filter settings are reasonable candidates. Modals, toasts, login-dialog state, request IDs, tokens, and personal data are not. The official middleware supports partialize, which filters the state before it is saved.
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
createCommerceUiSlice,
type CommerceUiState,
} from "./commerce-ui-store";
const noopStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
type PersistedCommerceUiState = Pick<CommerceUiState, "filters" | "cart">;
export const usePersistedCommerceUiStore = create<CommerceUiState>()(
persist(createCommerceUiSlice, {
name: "commerce-ui-v1",
version: 1,
storage: createJSONStorage(() =>
typeof window === "undefined" ? noopStorage : window.localStorage,
),
partialize: (state): PersistedCommerceUiState => ({
filters: state.filters,
cart: state.cart,
}),
}),
);
SSR needs extra care. On the server there is no localStorage; in the browser, persisted data may restore a cart count or filter value after hydration. If the server rendered zero cart items but the browser restores three items, the UI can mismatch. For values that depend on persisted browser state, either render them after mount or isolate them from SSR-critical markup.
Async Actions and Optimistic Updates
Optimistic updates make the UI feel faster by changing the screen before the server confirms the mutation. They work well for following, liking, and low-risk cart quantity changes. The failure mode is race conditions: an older request may finish after a newer request. The store should track a request ID and keep the previous UI snapshot for rollback.
import { create } from "zustand";
interface Profile {
id: string;
name: string;
isFollowing: boolean;
}
interface ProfileState {
profiles: Record<string, Profile>;
followRequestIds: Record<string, string>;
setProfile: (profile: Profile) => void;
followOptimistically: (profileId: string) => Promise<void>;
}
const removeKey = <T,>(record: Record<string, T>, key: string) => {
const { [key]: _removed, ...rest } = record;
return rest;
};
async function updateFollowOnServer(profileId: string, follow: boolean) {
const response = await fetch(`/api/profiles/${profileId}/follow`, {
method: follow ? "PUT" : "DELETE",
});
if (!response.ok) {
throw new Error(`Follow update failed: ${response.status}`);
}
}
export const useProfileStore = create<ProfileState>((set, get) => ({
profiles: {},
followRequestIds: {},
setProfile: (profile) =>
set((state) => ({
profiles: { ...state.profiles, [profile.id]: profile },
})),
followOptimistically: async (profileId) => {
const before = get().profiles[profileId];
if (!before) {
throw new Error("Profile not found");
}
const requestId = `${profileId}-${Date.now()}`;
set((state) => ({
profiles: {
...state.profiles,
[profileId]: { ...before, isFollowing: true },
},
followRequestIds: {
...state.followRequestIds,
[profileId]: requestId,
},
}));
try {
await updateFollowOnServer(profileId, true);
} catch (error) {
set((state) => {
if (state.followRequestIds[profileId] !== requestId) return state;
return {
profiles: { ...state.profiles, [profileId]: before },
followRequestIds: removeKey(state.followRequestIds, profileId),
};
});
throw error;
}
set((state) => {
if (state.followRequestIds[profileId] !== requestId) return state;
return {
followRequestIds: removeKey(state.followRequestIds, profileId),
};
});
},
}));
Give Claude Code the product rule, not only the API route. Say whether repeated clicks are allowed, what should be rolled back on failure, whether a toast should appear, and whether a later request should win. Otherwise Claude may generate clean async/await code that passes a happy-path test but fails under real user behavior.
Vitest Store Tests
Zustand actions can be tested without rendering React components. The key is resetting the store between tests, then asserting both behavior and design rules.
import { beforeEach, describe, expect, it } from "vitest";
import { useCommerceUiStore } from "./commerce-ui-store";
const initialState = useCommerceUiStore.getInitialState();
beforeEach(() => {
useCommerceUiStore.setState(initialState, true);
});
describe("commerce ui store", () => {
it("increments cart quantity and calculates total", () => {
const store = useCommerceUiStore.getState();
store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });
store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });
expect(useCommerceUiStore.getState().cart[0]?.quantity).toBe(2);
expect(useCommerceUiStore.getState().checkoutTotal()).toBe(2400);
});
it("resets the page when a filter changes", () => {
useCommerceUiStore.getState().setFilters({ page: 4 });
useCommerceUiStore.getState().setFilters({ keyword: "refund" });
expect(useCommerceUiStore.getState().filters).toMatchObject({
keyword: "refund",
page: 1,
});
});
it("keeps auth UI and toasts explicit", () => {
useCommerceUiStore.getState().setAuthStatus("signedIn");
useCommerceUiStore.getState().pushToast({
kind: "success",
message: "Saved",
});
expect(useCommerceUiStore.getState().auth.status).toBe("signedIn");
expect(useCommerceUiStore.getState().toasts).toHaveLength(1);
});
});
The test names should express the state contract: filter changes reset pagination, quantity zero removes a cart row, persisted data is limited, and failed optimistic updates roll back. This makes Claude Code’s later edits more predictable because the expected behavior is already written down.
Claude Code Review Prompt
After generating the code, ask Claude Code to review the diff with narrow criteria. A generic “review this” prompt is too soft; a state-management review must actively search for boundary mistakes, privacy issues, and rendering regressions.
Review only the Zustand state-management design in this diff.
Check:
1. Values that should stay in component local state were not moved to a global store.
2. Components use selectors instead of subscribing to the whole store.
3. persist partialize does not save PII, tokens, modal state, toasts, or request IDs.
4. SSR/hydration mismatches are handled for browser-restored values.
5. Async actions handle stale responses, repeated clicks, and rollback.
6. devtools and immer were not added without a concrete reason.
7. Vitest covers the main actions and at least one failure path.
Return:
- Blocking issues
- Suggested patches
- Missing tests
- Questions before merge
It is useful to run this prompt in a separate message after implementation. Claude tends to defend its own previous design when the instruction is vague. By forcing a critical checklist, you make it more likely to catch missing selectors, unsafe persist fields, and untested optimistic updates.
Pitfalls to Avoid
The first pitfall is moving everything into the global store. Typing text inside a small form, hover state, and a popover that never leaves one component tree usually belong in local state. Zustand should reduce coordination cost, not make every update travel through a shared global object.
The second pitfall is weak selector discipline. If a component subscribes to the whole store, unrelated updates trigger new renders. Ask Claude Code to scan every component that imports the store and replace broad subscriptions with named selectors.
The third pitfall is leaking PII through persist. Local storage is easy to inspect and dangerous during XSS. Never persist access tokens, email addresses, postal addresses, company names, inquiry text, or payment-session data. Persist anonymous UI preferences and low-risk cart fields only.
The fourth pitfall is SSR and hydration mismatch. Browser storage is not available on the server, so restored values can differ from server-rendered HTML. This matters for cart counts, theme, filters, and auth UI. Design the initial render intentionally.
The fifth pitfall is async race conditions. Repeated clicks, slow networks, tab switching, and stale responses can all break a naive async action. Use request IDs, aborts, or rollback rules. Also avoid adding immer, devtools, and other middleware simply because they exist; each one should have a reason.
Claude Code Lab Support
Claude Code Lab’s training and consulting can help teams audit an existing React application, decide what belongs in Zustand, keep server state in TanStack Query, define safe persist rules, and create review prompts that engineers can reuse. This is especially useful for admin dashboards and ecommerce flows where filters, carts, auth UI, and notifications interact.
When you ask for help, bring the current store files, localStorage keys, authentication method, SSR framework, slow screens, and the state that users expect to survive reloads. The hardest part is rarely writing create; it is deciding which state should still exist after navigation, reload, logout, and failed network requests.
Verification Memo
As of June 2, 2026, this article was checked against the Zustand official docs for create, persist, useShallow, and testing. The code examples are written as copyable TypeScript/TSX snippets. In practice, the teams that first wrote a “safe to persist” table spent less time later removing PII from localStorage or fixing hydration mismatches.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.