React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zustandstorepatternsactionscomputed

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.
  • set provides partial state merging. get provides 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) => state receives three arguments: set for updates, get for reading, and api for subscriptions and the full store API.
  • Actions defined inside the store have closure over set and get, 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 use subscribeWithSelector.
  • 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 set multiple times in sequence will trigger multiple renders. Batch related changes into a single set call.
  • Storing derived state (like total) alongside source state (like items) creates a synchronization risk. Prefer computing derived values on the fly.

Alternatives

ApproachProsCons
Colocated actionsSelf-contained, easy to discoverLarge store file for complex domains
External actionsTestable, decoupledHarder to find all actions for a store
Grouped actions objectStable reference, no re-rendersExtra nesting, less conventional
Separate stores per domainFocused, independentCross-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 with previousPoints.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 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.
  • 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 useMemo instead.
What is the "colocated actions" pattern?
  • Actions are defined inside the store alongside state values.
  • They have closure over set and get, 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 actions object in the store.
  • Selecting (s) => s.actions gives 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 set call triggers a state update and potential re-render.
  • Batch related changes into a single set call 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 -- total could get out of sync with items.
  • 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.