# 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 (
);
}
```
---
### 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