State Management Overview
Every React app has state — but not all state is the same. The most common mistake developers make when an app grows is reaching for a single state management solution for all state, regardless of what that state represents. This leads to global stores full of loading flags, or complex cache invalidation logic in what should be simple UI toggles. The key insight: there are three distinct kinds of state, and each has a different optimal solution.
The Three Types of State
Type | Examples | Best Tool |
|---|---|---|
UI / Local state | isOpen, inputValue, activeTab, isExpanded | useState / useReducer |
Server / Async state | fetched users, loading flag, error, cache | TanStack Query / SWR |
Global client state | auth user, theme, shopping cart, feature flags | Zustand / Redux |
Type 1 — UI / Local State
UI state is ephemeral: it exists only to control the interface. Whether a dropdown is open, which tab is active, what text is in an input — this state is inherently local to a component or a small subtree. Using a global store for it is massive overkill:
import { useState } from 'react'
// ✓ Local state — perfect use case for useState
function Accordion({ title, children }) {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(o => !o)}>
{title} {isOpen ? '▲' : '▼'}
</button>
{isOpen && <div>{children}</div>}
</div>
)
}
// ✓ Multiple related fields → useReducer keeps them consistent
function LoginForm() {
const [form, dispatch] = useReducer(
(state, action) => ({ ...state, ...action }),
{ email: '', password: '', isSubmitting: false }
)
return (
<form>
<input
value={form.email}
onChange={e => dispatch({ email: e.target.value })}
/>
<input
type="password"
value={form.password}
onChange={e => dispatch({ password: e.target.value })}
/>
</form>
)
}Type 2 — Server / Async State
Server state is data that lives on a server and is synchronized into your UI. It is asynchronous (requires a network round-trip), shared (the server is the source of truth, not your client), and potentially stale (someone else may have changed it). Managing this manually with useState + useEffect is tedious and error-prone. Libraries like TanStack Query (React Query) or SWR exist specifically for this pattern:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
// ✓ TanStack Query handles: loading, error, caching, deduplication,
// background refetch, stale-while-revalidate, and pagination
function UserList() {
const { data: users, isLoading, isError } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
staleTime: 60_000, // consider data fresh for 1 minute
})
if (isLoading) return <p>Loading…</p>
if (isError) return <p>Error loading users</p>
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
)
}
// Mutations invalidate the cache and trigger a refetch automatically
function AddUserButton() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (user) => fetch('/api/users', {
method: 'POST',
body: JSON.stringify(user),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
return (
<button onClick={() => mutation.mutate({ name: 'New User' })}>
Add User
</button>
)
}Type 3 — Global Client State
Global client state is data that is: (a) not from the server, (b) needed by many components across the tree, and (c) persists for the session. Classic examples: the authenticated user object, the active theme, the shopping cart, notification preferences:
import { create } from 'zustand'
// ✓ Zustand — minimal, no boilerplate, selector-based subscriptions
const useAppStore = create(set => ({
user: null,
theme: 'light',
notifications: [],
setUser: user => set({ user }),
setTheme: theme => set({ theme }),
addNotification: (msg) => set(state => ({
notifications: [...state.notifications, { id: Date.now(), msg }],
})),
dismissNotification: (id) => set(state => ({
notifications: state.notifications.filter(n => n.id !== id),
})),
}))
// Components only subscribe to what they need
function UserAvatar() {
const user = useAppStore(state => state.user)
return user ? <img src={user.avatarUrl} alt={user.name} /> : null
}
function ThemeToggle() {
const theme = useAppStore(state => state.theme)
const setTheme = useAppStore(state => state.setTheme)
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
)
}The Common Mistake: One Tool for Everything
// ✗ The kitchen-sink Redux store — antipattern
const store = {
// Global client state (fine for Redux)
user: null,
theme: 'light',
// Server state (should be in TanStack Query)
users: [],
usersLoading: false,
usersError: null,
usersLastFetched: null,
// UI state (should be useState in the component)
isModalOpen: false,
activeTab: 'overview',
searchQuery: '',
}
// ✓ Each in its natural home
// useState → isModalOpen, activeTab, searchQuery
// TanStack Q → users (with built-in loading/error/cache)
// Zustand → user, theme (true global client state)The Prop Drilling Spectrum
When state needs to be shared between components, the right solution depends on how many levels deep it needs to travel:
1-2 levels — pass props directly. Simple, explicit, no abstraction needed.
3-4 levels — lift state to the nearest common ancestor and pass down. Still manageable.
5+ levels or many consumers — reach for Context (for infrequently-changing values like theme/locale) or Zustand (for frequently-changing values).
Complex interactions, time-travel debugging, strict action contracts — Redux.
Decision Flowchart
Is this state needed by only 1 component? └─ YES → useState / useReducer inside the component Is this data fetched from a server (async)? └─ YES → TanStack Query or SWR Is this state needed by many components across the tree, and it changes infrequently (theme, locale, auth)? └─ YES → React Context Is this state needed by many components and changes frequently? └─ YES → Zustand or Jotai Do you need time-travel debugging, strict action contracts, complex middleware, or enterprise team conventions? └─ YES → Redux Toolkit
How Complexity Grows with App Size
App size | Typical state setup |
|---|---|
Single page / prototype | useState everywhere, fetch in useEffect |
Small app (< 10 pages) | useState + TanStack Query |
Medium app (10–50 pages) | useState + TanStack Query + Zustand for 1–2 global slices |
Large / enterprise app | useState + TanStack Query + Redux Toolkit (complex interactions) |
UI state — local, ephemeral, controls the interface →
useState/useReducer.Server state — async, shared with server, can be stale → TanStack Query / SWR.
Global client state — session-scoped, needed across the tree → Zustand / Redux.
Mix tools: most apps use all three categories simultaneously.
Start simple. Add complexity only when you feel the pain of the simpler solution.