//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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),
}));// 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>
);
}set provides partial state merging. get provides read access to the current state - useful for computed values and async actions.get()) are evaluated lazily each time they are called, not cached.(set, get, api) => state receives three arguments: set for updates, get for reading, and api for subscriptions and the full store API.set and get, keeping them self-contained.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);create<State>.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 }),
}));getTotal()) are not cached. Every call re-computes. For expensive computations, derive values in the selector or use subscribeWithSelector.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.set multiple times in sequence will trigger multiple renders. Batch related changes into a single set call.total) alongside source state (like items) creates a synchronization risk. Prefer computing derived values on the fly.| 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 |
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:
new Map(previousPoints) creates a new Map reference at every step. Zustand uses reference equality to detect changes. If you mutated the existing Map with previousPoints.set(pointId, isSaved), Zustand would not detect the change and would not trigger a re-render.new Map(previousPoints) rather than just set({ savedPoints: previousPoints }). This is necessary because previousPoints is 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().savedPoints captures the current state before the optimistic update. This snapshot is used for rollback. If you read get().savedPoints inside the catch block, it would return the optimistic state, not the pre-update state.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.get(): getTotal: () => get().items.reduce(...).useMemo instead.set and get, keeping them self-contained.const useStore = create<State>((set) => ({ count: 0 }));
export const increment = () =>
useStore.setState((s) => ({ count: s.count + 1 }));store.setState.actions object in the store.(s) => s.actions gives a stable reference that never triggers re-renders.(s) => s.token !== null.set call triggers a state update and potential re-render.set call to avoid multiple renders.// Bad: two renders
set({ isLoading: false });
set({ data: result });
// Good: one render
set({ isLoading: false, data: result });total could get out of sync with items.get() or in selectors.get() before the async operation.set (new reference).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 }),
}));type StoreState = ReturnType<typeof useStore.getState>;Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥