State Management
Choose the right state layer for each type of state. Most state should live closer to the server than you think.
State management decides where state lives and how it flows. The most common mistake: centralising state that should stay local, and localising state that should be URL or server state. Start with the least powerful option.
Categories of State
| Category | Examples | Preferred storage |
|---|---|---|
| UI state | Modal open/closed, accordion expanded, tooltip visible | useState |
| Form state | Field values, validation errors (in-progress input) | react-hook-form or useState |
| URL state | Filters, selected tab, pagination, search query | URL search params |
| Server state | User profile, project list, API responses | SWR or TanStack Query |
| Global UI state | Current user, theme, notification queue, feature flags | React Context or Zustand |
In a well-architected app, most state is URL or server state. Reaching for a global store for "data from the server" usually means a missing caching layer.
Local Component State
useState for state owned by a single component. Lift up only when siblings genuinely need to share.
function Disclosure({ title, children }) {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-controls="disclosure-content"
>
{title}
</button>
<div id="disclosure-content" hidden={!isOpen}>
{children}
</div>
</div>
)
}URL State
State users might share, bookmark, or return to — filters, sort order, pagination, tabs, search queries — belongs in the URL. It requires no library and survives refreshes.
Server State
Remote data that must be fetched, cached, and synced. Avoid useEffect + useState — it's error-prone boilerplate. Use SWR or TanStack Query.
Global Client State
Reserve for genuinely application-wide state that can't live in URL or derive from server. Legitimate uses: current user, active theme, notification queue.
import { create } from "zustand"
import { persist } from "zustand/middleware"
interface NotificationStore {
notifications: Notification[]
add: (notification: Omit<Notification, "id">) => void
dismiss: (id: string) => void
}
export const useNotifications = create<NotificationStore>((set) => ({
notifications: [],
add: (notification) =>
set((state) => ({
notifications: [...state.notifications, { ...notification, id: crypto.randomUUID() }],
})),
dismiss: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
})),
}))Tool Selection Guide
| Tool | Use for | Do not use for |
|---|---|---|
| useState | Component-local UI state | Data shared across pages |
| useReducer | Complex local state with multiple transitions | Server data, URL state |
| URL search params | Filters, pagination, tabs, search | Sensitive data, large objects |
| React Context | Low-frequency global state: theme, current user, locale | High-frequency updates — causes re-renders |
| SWR | Server data: fetching, caching, mutations, polling | Non-remote state |
| Zustand | Client global state with complex update logic | Simple state that fits in Context |
| Jotai | Fine-grained reactive state where Context would over-render | Replacing server state |