Store Design Patterns
Recipe
Design stores with colocated actions, derived (computed) values, and a clear separation between state and behavior. Keep stores focused on a single domain.
import { create } from "zustand";
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
// State
items: CartItem[];
// Actions
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
// Computed (as getters)
getTotal: () => number;
getItemCount: () => number;
}
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
})),
clearCart: () => set({ items: [] }),
getTotal: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
getItemCount: () => get().items.reduce((sum, i) => sum + i.quantity, 0),
}));Working Example
// stores/auth-store.ts
import { create } from "zustand";
interface User {
id: string;
name: string;
email: string;
role: "admin" | "user";
}
interface AuthStore {
// State
user: User | null;
token: string | null;
isHydrated: boolean;
// Actions
login: (email: string, password: string) => Promise<void>;
logout: () => void;
updateProfile: (updates: Partial<User>) => void;
// Computed
isAuthenticated: () => boolean;
isAdmin: () => boolean;
}
export const useAuthStore = create<AuthStore>((set, get) => ({
user: null,
token: null,
isHydrated: false,
login: async (email, password) => {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("Login failed");
const { user, token } = await res.json();
set({ user, token });
},
logout: () => set({ user: null, token: null }),
updateProfile: (updates) =>
set((state) => ({
user: state.user ? { ...state.user, ...updates } : null,
})),
isAuthenticated: () => get().token !== null,
isAdmin: () => get().user?.role === "admin",
}));// components/auth-guard.tsx
"use client";
import { useAuthStore } from "@/stores/auth-store";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export function AuthGuard({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated());
const router = useRouter();
useEffect(() => {
if (!isAuthenticated) {
router.push("/login");
}
}, [isAuthenticated, router]);
if (!isAuthenticated) return null;
return <>{children}</>;
}// components/user-menu.tsx
"use client";
import { useAuthStore } from "@/stores/auth-store";
export function UserMenu() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const isAdmin = useAuthStore((s) => s.isAdmin());
if (!user) return null;
return (
<div>
<span>{user.name}</span>
{isAdmin && <a href="/admin">Admin Panel</a>}
<button onClick={logout}>Logout</button>
</div>
);
}Deep Dive
How It Works
- Zustand stores are plain objects. Actions are just functions stored alongside state values.
setprovides partial state merging.getprovides read access to the current state — useful for computed values and async actions.- Computed values as getter functions (using
get()) are evaluated lazily each time they are called, not cached. - The store creator function
(set, get, api) => statereceives three arguments:setfor updates,getfor reading, andapifor subscriptions and the full store API. - Actions defined inside the store have closure over
setandget, keeping them self-contained.
Variations
Separate actions from state:
// Some teams prefer actions outside the store
const useStore = create<State>((set) => ({
count: 0,
}));
// Actions as standalone functions
export const increment = () => useStore.setState((s) => ({ count: s.count + 1 }));
export const reset = () => useStore.setState({ count: 0 });Computed values with subscribe (cached):
import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
const useStore = create(
subscribeWithSelector<{ items: Item[]; total: number }>((set) => ({
items: [],
total: 0,
}))
);
// Update total whenever items change
useStore.subscribe(
(s) => s.items,
(items) => useStore.setState({ total: items.reduce((s, i) => s + i.price, 0) })
);Grouped actions pattern:
interface Store {
count: number;
actions: {
increment: () => void;
decrement: () => void;
reset: () => void;
};
}
const useStore = create<Store>((set) => ({
count: 0,
actions: {
increment: () => set((s) => ({ count: s.count + 1 })),
decrement: () => set((s) => ({ count: s.count - 1 })),
reset: () => set({ count: 0 }),
},
}));
// Select actions once (stable reference, never triggers re-render)
const { increment } = useStore((s) => s.actions);TypeScript Notes
- Define the state interface explicitly and pass it to
create<State>. - Separate state types from action types if the interface grows large.
- Use
ReturnType<typeof useStore.getState>to infer the store type dynamically.
interface State {
count: number;
}
interface Actions {
increment: () => void;
reset: () => void;
}
type Store = State & Actions;
const useStore = create<Store>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
reset: () => set({ count: 0 }),
}));Gotchas
- Getter functions (
getTotal()) are not cached. Every call re-computes. For expensive computations, derive values in the selector or usesubscribeWithSelector. - Calling
isAuthenticated()as a selector(s) => s.isAuthenticated()re-evaluates on every state change since the function always returns a new value. Select the underlying state instead:(s) => s.token !== null. - Deeply nested state updates require manual spreading at every level. Consider the immer middleware for complex nested state.
- Actions that call
setmultiple times in sequence will trigger multiple renders. Batch related changes into a singlesetcall. - Storing derived state (like
total) alongside source state (likeitems) creates a synchronization risk. Prefer computing derived values on the fly.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Colocated actions | Self-contained, easy to discover | Large store file for complex domains |
| External actions | Testable, decoupled | Harder to find all actions for a store |
| Grouped actions object | Stable reference, no re-renders | Extra nesting, less conventional |
| Separate stores per domain | Focused, independent | Cross-store coordination needed |
Real-World Example
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// Production example: Optimistic update with rollback in a Zustand store
// File: src/stores/project-store.ts (savePoint action)
import { create } from 'zustand';
interface ProjectStore {
savedPoints: Map<string, boolean>;
savePoint: (pointId: string, isSaved: boolean) => Promise<void>;
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
savedPoints: new Map(),
savePoint: async (pointId: string, isSaved: boolean) => {
const previousPoints = get().savedPoints;
// 1. Optimistically update the UI immediately
const optimisticPoints = new Map(previousPoints);
optimisticPoints.set(pointId, isSaved);
set({ savedPoints: optimisticPoints });
try {
// 2. Send the change to the API
const res = await fetch('/api/save-point', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pointId, isSaved }),
});
if (!res.ok) throw new Error('Failed to save');
} catch (error) {
// 3. Rollback to the previous state on failure
set({ savedPoints: new Map(previousPoints) });
console.error('Save failed, rolled back:', error);
}
},
}));What this demonstrates in production:
- The optimistic update pattern shows the UI change instantly (step 1), sends the API request in the background (step 2), and rolls back if the request fails (step 3). Users see immediate feedback without waiting for the network round trip.
new Map(previousPoints)creates a new Map reference at every step. Zustand uses reference equality to detect changes. If you mutated the existing Map withpreviousPoints.set(pointId, isSaved), Zustand would not detect the change and would not trigger a re-render.- The rollback in the catch block also creates a
new Map(previousPoints)rather than justset({ savedPoints: previousPoints }). This is necessary becausepreviousPointsis the same reference that was in the store before the optimistic update. If any component captured that reference, setting it back without creating a new Map could fail to trigger re-renders. get().savedPointscaptures the current state before the optimistic update. This snapshot is used for rollback. If you readget().savedPointsinside the catch block, it would return the optimistic state, not the pre-update state.- This pattern works well for toggle-style actions (save/unsave, like/unlike) where the UI change is binary and easy to reverse.
FAQs
What are the three arguments the store creator function receives?
set-- for updating state (partial merge or function).get-- for reading current state (useful for computed values and async actions).api-- the full store API for subscriptions and advanced usage.
How do computed values (getter functions) work in Zustand?
- Define them as functions using
get():getTotal: () => get().items.reduce(...). - They are evaluated lazily each time they are called, not cached.
- For expensive computations, use selectors with
useMemoinstead.
What is the "colocated actions" pattern?
- Actions are defined inside the store alongside state values.
- They have closure over
setandget, keeping them self-contained. - This is the most common and recommended Zustand pattern.
What is the "separated actions" pattern and when would you use it?
const useStore = create<State>((set) => ({ count: 0 }));
export const increment = () =>
useStore.setState((s) => ({ count: s.count + 1 }));- Actions are standalone functions using
store.setState. - Useful when you want actions to be testable independently from the store.
What is the "grouped actions" pattern?
- Nest all actions inside an
actionsobject in the store. - Selecting
(s) => s.actionsgives a stable reference that never triggers re-renders. - Trade-off: extra nesting, less conventional.
Gotcha: Why does calling (s) => s.isAuthenticated() as a selector re-render on every state change?
- The function is called during render and returns a new value each time.
- Zustand sees it as a changed value because the selector calls a function.
- Instead, select the underlying state:
(s) => s.token !== null.
Gotcha: What happens if you call set multiple times in sequence within an action?
- Each
setcall triggers a state update and potential re-render. - Batch related changes into a single
setcall to avoid multiple renders.
// Bad: two renders
set({ isLoading: false });
set({ data: result });
// Good: one render
set({ isLoading: false, data: result });Why is storing derived state (like total) alongside source state (like items) risky?
- It creates a synchronization risk --
totalcould get out of sync withitems. - Prefer computing derived values on the fly using
get()or in selectors.
How does the optimistic update pattern work in the production example?
- Snapshot current state with
get()before the async operation. - Apply the optimistic change immediately with
set(new reference). - If the API call fails, roll back by setting a new copy of the snapshot.
How do you type a store with separate State and Actions interfaces in TypeScript?
interface State { count: number; }
interface Actions { increment: () => void; reset: () => void; }
type Store = State & Actions;
const useStore = create<Store>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
reset: () => set({ count: 0 }),
}));How do you infer the store type dynamically in TypeScript?
type StoreState = ReturnType<typeof useStore.getState>;- This infers both state and actions from the actual store implementation.