# State Management — Patterns # State Management Patterns ## When to Use - Choosing between local, shared, or global state in a React application - Setting up server state caching with TanStack Query or SWR - Building forms with validation, arrays, and nested fields - Syncing application state with URL search parameters - Designing Python domain models with dataclasses or Pydantic - Refactoring prop-drilling into a shared store - Deciding whether to add a state management library or keep things simple ## When NOT to Use - Static sites with no interactive state (pure content pages, docs) - Server-only rendering with no client-side interactivity - Simple CRUD backends where database is the only source of truth and there is no in-process state to manage --- ## Core Patterns ### 1. Local vs Global State Decision Tree Before reaching for a library, walk through this decision tree. ``` Is the state used by a single component? ├── YES --> useState or useReducer └── NO Is it shared by a parent and 1-2 direct children? ├── YES --> Lift state up to the common parent, pass via props └── NO Is it server data (fetched from an API)? ├── YES --> TanStack Query (useQuery / useMutation) └── NO Is it URL-representable (filters, pagination, tabs)? ├── YES --> URL state (useSearchParams / nuqs) └── NO Is it form data with validation? ├── YES --> react-hook-form + zod └── NO Zustand store (or Jotai for atomic state) ``` **Rules of thumb:** - Start with the simplest option. Only add a library when props become painful. - Server state and client state are different concerns. Never put fetched API data in Zustand; use TanStack Query instead. - URL state is free persistence. If the user should be able to bookmark or share the current view, put it in the URL. - Form state belongs to the form library. Do not mirror react-hook-form values in a Zustand store. --- ### 2. React State Patterns **useState for simple values** ```typescript function Counter() { const [count, setCount] = useState(0); return ( ); } ``` **useReducer for complex state with multiple transitions** ```typescript interface TimerState { status: "idle" | "running" | "paused"; elapsed: number; } type TimerAction = | { type: "start" } | { type: "pause" } | { type: "reset" } | { type: "tick" }; function timerReducer(state: TimerState, action: TimerAction): TimerState { switch (action.type) { case "start": return { ...state, status: "running" }; case "pause": return { ...state, status: "paused" }; case "reset": return { status: "idle", elapsed: 0 }; case "tick": return state.status === "running" ? { ...state, elapsed: state.elapsed + 1 } : state; } } function Timer() { const [state, dispatch] = useReducer(timerReducer, { status: "idle", elapsed: 0, }); useEffect(() => { if (state.status !== "running") return; const id = setInterval(() => dispatch({ type: "tick" }), 1000); return () => clearInterval(id); }, [state.status]); return (

{state.elapsed}s

{state.status !== "running" && ( )} {state.status === "running" && ( )}
); } ``` **When to pick which:** | Criteria | useState | useReducer | |----------|----------|------------| | Single primitive value | Yes | Overkill | | Multiple related fields | Possible | Preferred | | Complex transitions | Messy | Clean | | Needs testing in isolation | Hard | Easy (test the reducer) | --- ### 3. Global State (Zustand) Zustand is lightweight, TypeScript-friendly, and avoids the boilerplate of Redux. **Basic store** ```typescript import { create } from "zustand"; interface AuthStore { user: User | null; token: string | null; login: (user: User, token: string) => void; logout: () => void; } const useAuthStore = create((set) => ({ user: null, token: null, login: (user, token) => set({ user, token }), logout: () => set({ user: null, token: null }), })); // In components - use selectors to avoid unnecessary re-renders function UserMenu() { const user = useAuthStore((s) => s.user); const logout = useAuthStore((s) => s.logout); if (!user) return ; return (
{user.name}
); } ``` **Slices pattern for large stores** ```typescript import { create, type StateCreator } from "zustand"; import { devtools, persist } from "zustand/middleware"; // Each slice is its own interface + creator interface CartSlice { items: CartItem[]; addItem: (item: CartItem) => void; removeItem: (id: string) => void; clearCart: () => void; } interface UISlice { sidebarOpen: boolean; toggleSidebar: () => void; } const createCartSlice: StateCreator< CartSlice & UISlice, [], [], CartSlice > = (set) => ({ items: [], addItem: (item) => set((state) => ({ items: [...state.items, item] })), removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id) })), clearCart: () => set({ items: [] }), }); const createUISlice: StateCreator< CartSlice & UISlice, [], [], UISlice > = (set) => ({ sidebarOpen: false, toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), }); // Combine slices with middleware const useAppStore = create()( devtools( persist( (...args) => ({ ...createCartSlice(...args), ...createUISlice(...args), }), { name: "app-store", partialize: (state) => ({ items: state.items }), // only persist cart } ) ) ); ``` **Zustand best practices:** - Always use selectors (`useStore((s) => s.field)`) instead of the whole store. - Keep stores small and focused. One store per domain, not one mega-store. - Use `persist` middleware for state that should survive page reloads. - Use `devtools` middleware in development for Redux DevTools integration. --- ### 4. Server State (TanStack Query) Server state (data from APIs) has different needs than client state: caching, background refetching, deduplication, pagination. **Basic query** ```typescript import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; // Query key conventions: [entity, ...params] const userKeys = { all: ["users"] as const, list: (filters: UserFilters) => ["users", "list", filters] as const, detail: (id: string) => ["users", "detail", id] as const, }; function useUser(id: string) { return useQuery({ queryKey: userKeys.detail(id), queryFn: () => api.users.get(id), staleTime: 5 * 60 * 1000, // 5 minutes }); } function UserProfile({ id }: { id: string }) { const { data: user, isLoading, error } = useUser(id); if (isLoading) return ; if (error) return ; return
{user.name}
; } ``` **Mutations with optimistic updates** ```typescript function useUpdateUser() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: UpdateUserInput) => api.users.update(data.id, data), onMutate: async (newData) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: userKeys.detail(newData.id), }); // Snapshot current value for rollback const previous = queryClient.getQueryData(userKeys.detail(newData.id)); // Optimistically update queryClient.setQueryData(userKeys.detail(newData.id), (old: User) => ({ ...old, ...newData, })); return { previous }; }, onError: (_err, newData, context) => { // Rollback on failure if (context?.previous) { queryClient.setQueryData( userKeys.detail(newData.id), context.previous ); } }, onSettled: (_data, _err, variables) => { // Always refetch after mutation to ensure consistency queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id), }); }, }); } ``` **Prefetching for instant navigation** ```typescript function UserListItem({ user }: { user: UserSummary }) { const queryClient = useQueryClient(); const prefetch = () => { queryClient.prefetchQuery({ queryKey: userKeys.detail(user.id), queryFn: () => api.users.get(user.id), staleTime: 60_000, }); }; return ( {user.name} ); } ``` --- ### 5. Form State Use react-hook-form for performance (uncontrolled inputs) and zod for schema validation. **Basic form with validation** ```typescript import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; const createUserSchema = z.object({ name: z.string().min(1, "Name is required").max(100), email: z.string().email("Invalid email address"), role: z.enum(["admin", "editor", "viewer"]), notifications: z.boolean().default(true), }); type CreateUserForm = z.infer; function CreateUserForm() { const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(createUserSchema), defaultValues: { role: "viewer", notifications: true, }, }); const onSubmit = async (data: CreateUserForm) => { await api.users.create(data); }; return (
{errors.name &&

{errors.name.message}

}
{errors.email &&

{errors.email.message}

}
); } ``` **Dynamic field arrays** ```typescript import { useFieldArray } from "react-hook-form"; const orderSchema = z.object({ customer: z.string().min(1), items: z .array( z.object({ productId: z.string().min(1), quantity: z.number().int().min(1).max(999), }) ) .min(1, "At least one item is required"), }); type OrderForm = z.infer; function OrderForm() { const { register, control, handleSubmit } = useForm({ resolver: zodResolver(orderSchema), defaultValues: { items: [{ productId: "", quantity: 1 }] }, }); const { fields, append, remove } = useFieldArray({ control, name: "items", }); return (
{fields.map((field, index) => (
))}
); } ``` --- ### 6. URL State Encode filters, pagination, and view settings in the URL so users can bookmark and share. **Using nuqs (type-safe URL search params)** ```typescript import { useQueryState, parseAsInteger, parseAsStringEnum } from "nuqs"; const sortOptions = ["name", "date", "price"] as const; function ProductList() { const [search, setSearch] = useQueryState("q", { defaultValue: "" }); const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1)); const [sort, setSort] = useQueryState( "sort", parseAsStringEnum(sortOptions).withDefault("name") ); // URL looks like: /products?q=shoes&page=2&sort=price const { data } = useQuery({ queryKey: ["products", { search, page, sort }], queryFn: () => api.products.list({ search, page, sort }), }); return (
setSearch(e.target.value || null)} placeholder="Search products..." />
); } ``` **Using React Router useSearchParams** ```typescript import { useSearchParams } from "react-router-dom"; function FilteredList() { const [searchParams, setSearchParams] = useSearchParams(); const status = searchParams.get("status") ?? "all"; const page = Number(searchParams.get("page") ?? "1"); const updateFilter = (key: string, value: string | null) => { setSearchParams((prev) => { const next = new URLSearchParams(prev); if (value === null) { next.delete(key); } else { next.set(key, value); } // Reset page when filter changes if (key !== "page") next.set("page", "1"); return next; }); }; return (
); } ``` --- ### 7. Python State Use dataclasses for lightweight domain objects and Pydantic for validated external data. Combine with the repository pattern for persistence. **Dataclasses for domain objects** ```python from dataclasses import dataclass, field from datetime import datetime from enum import Enum from uuid import UUID, uuid4 class OrderStatus(str, Enum): DRAFT = "draft" CONFIRMED = "confirmed" SHIPPED = "shipped" DELIVERED = "delivered" CANCELLED = "cancelled" @dataclass class OrderItem: product_id: str quantity: int unit_price: float @property def total(self) -> float: return self.quantity * self.unit_price @dataclass class Order: customer_id: str items: list[OrderItem] = field(default_factory=list) id: UUID = field(default_factory=uuid4) status: OrderStatus = OrderStatus.DRAFT created_at: datetime = field(default_factory=datetime.utcnow) @property def subtotal(self) -> float: return sum(item.total for item in self.items) def confirm(self) -> None: if self.status != OrderStatus.DRAFT: raise ValueError(f"Cannot confirm order in '{self.status}' state") if not self.items: raise ValueError("Cannot confirm an empty order") self.status = OrderStatus.CONFIRMED def cancel(self) -> None: if self.status in (OrderStatus.DELIVERED, OrderStatus.CANCELLED): raise ValueError(f"Cannot cancel order in '{self.status}' state") self.status = OrderStatus.CANCELLED ``` **Pydantic for validated external input** ```python from pydantic import BaseModel, Field, field_validator class CreateOrderRequest(BaseModel): customer_id: str = Field(min_length=1, max_length=50) items: list["OrderItemInput"] = Field(min_length=1) @field_validator("items") @classmethod def no_duplicate_products(cls, items: list["OrderItemInput"]) -> list["OrderItemInput"]: product_ids = [item.product_id for item in items] if len(product_ids) != len(set(product_ids)): raise ValueError("Duplicate product IDs are not allowed") return items class OrderItemInput(BaseModel): product_id: str = Field(min_length=1) quantity: int = Field(ge=1, le=999) ``` **Repository pattern for persistence** ```python from abc import ABC, abstractmethod class OrderRepository(ABC): @abstractmethod async def save(self, order: Order) -> None: ... @abstractmethod async def get(self, order_id: UUID) -> Order | None: ... @abstractmethod async def list_by_customer(self, customer_id: str) -> list[Order]: ... class PostgresOrderRepository(OrderRepository): def __init__(self, pool) -> None: self.pool = pool async def save(self, order: Order) -> None: async with self.pool.acquire() as conn: await conn.execute( """ INSERT INTO orders (id, customer_id, status, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO UPDATE SET status = $3 """, order.id, order.customer_id, order.status.value, order.created_at, ) # Upsert items... async def get(self, order_id: UUID) -> Order | None: async with self.pool.acquire() as conn: row = await conn.fetchrow( "SELECT * FROM orders WHERE id = $1", order_id ) if row is None: return None items = await conn.fetch( "SELECT * FROM order_items WHERE order_id = $1", order_id ) return self._row_to_order(row, items) async def list_by_customer(self, customer_id: str) -> list[Order]: async with self.pool.acquire() as conn: rows = await conn.fetch( "SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC", customer_id, ) # Fetch items for each order... return [self._row_to_order(row, items) for row, items in results] def _row_to_order(self, row, item_rows) -> Order: return Order( id=row["id"], customer_id=row["customer_id"], status=OrderStatus(row["status"]), created_at=row["created_at"], items=[ OrderItem( product_id=r["product_id"], quantity=r["quantity"], unit_price=float(r["unit_price"]), ) for r in item_rows ], ) ``` --- ## Best Practices 1. **Start local, promote when needed.** Begin with `useState`. Only move state up or into a store when two or more unrelated components need the same data. Premature globalization makes refactoring painful. 2. **Separate server state from client state.** Use TanStack Query (or SWR) for anything fetched from an API. These libraries handle caching, deduplication, background refetching, and stale-while-revalidate. Do not duplicate fetched data in Zustand. 3. **Use selectors to prevent re-renders.** In Zustand, always select the specific field you need: `useStore((s) => s.count)`, not `useStore()`. The latter re-renders on every store change. 4. **Co-locate state with the component that owns it.** If only `` uses `isOpen`, keep that state inside ``. Moving it to a global store just because "it might be needed later" creates unnecessary coupling. 5. **Derive, do not duplicate.** If `fullName` can be computed from `firstName` and `lastName`, compute it on the fly or with `useMemo`. Storing derived values introduces synchronization bugs. 6. **Validate at the boundary, trust internally.** Use Pydantic or Zod to validate data when it enters the system (API requests, form submissions, external events). Once validated, pass typed objects without re-checking. 7. **Keep URL state minimal.** Only encode values the user would want to bookmark or share: active tab, search query, page number, sort column. Do not put ephemeral UI state (hover, open dropdown) in the URL. 8. **Treat form state as its own domain.** Let react-hook-form manage form values, dirty tracking, and validation. Submit the validated result to your mutation or API call. Do not synchronize form fields with external stores. --- ## Common Pitfalls 1. **Putting everything in global state.** Not all state needs to be global. A modal's open/closed state, an input's current text, or a component's loading spinner should stay local. Global stores should hold state that genuinely needs to be shared across distant parts of the tree. 2. **Storing server data in Zustand.** Zustand has no built-in cache invalidation, stale detection, or background refetch. Using it for API data means you are rebuilding TanStack Query poorly. Use the right tool for the job. 3. **Forgetting to invalidate queries after mutations.** After a `useMutation` succeeds, call `queryClient.invalidateQueries` with the affected keys. Without this, the UI shows stale data until the next refetch interval. 4. **Over-using React Context for frequently changing state.** Every Context value change re-renders every consumer. Context is good for low-frequency values (theme, locale, auth). For high-frequency updates (cursor position, scroll offset), use Zustand or a ref. 5. **Duplicating form state.** Calling `useForm()` and then also storing the same values in `useState` or Zustand means two sources of truth that can drift apart. Let the form library be the single owner. 6. **Ignoring URL state for filterable lists.** If a user applies filters and then hits the back button or refreshes, losing the filters is a bad experience. Encode filters in the URL so they survive navigation. --- ## Related Skills - `react` - React component patterns and hooks - `nextjs` - Next.js server components and data fetching - `typescript` - TypeScript types and generics - `caching` - Cache strategies and invalidation patterns