React State Management: useState vs Context vs Zustand vs Redux
Most React apps need less state management than they think. Sort your state into server, URL, form, local and global, then pick the lightest tool for each: useState, the URL, React Context, Zustand, Redux Toolkit or Jotai. Trade-offs, examples and common mistakes.
"Which state management library should I use?" usually has a surprising answer: probably less than you think. Most state in a React app isn't "global state" at all — it's server data, URL state, or form state, each of which has a better home than a global store.
Sort your state first; then pick tools.
Five kinds of state
| Kind | Examples | Best home |
|---|---|---|
| Server state | Users, projects, orders from your API | A server-state cache (TanStack Query, SWR) or Server Components |
| URL state | Current tab, filters, search query, page number | The URL (search params, route) |
| Form state | Field values, validation errors | Local state or a form library (React Hook Form) |
| Local UI state | Is this dropdown open? Hovered row? | useState in that component |
| Global client state | Theme, logged-in user's preferences, a cart before checkout, a multi-step wizard | Context or a small store |
The last row is the only one that needs "state management." For many apps it's a handful of values.
Server state: not your store's job
Fetching data and copying it into Redux or Context means maintaining a second copy of your database in the browser — with loading, caching, refetching and invalidation all done by hand. Use TanStack Query (or Server Components) instead. (TanStack Query vs useEffect)
URL state: free persistence and sharing
Filters, sort order, tabs and pagination belong in the URL:
/projects?status=active&sort=updated&page=2
Refresh keeps them, links share them, the back button works. In React Router, useSearchParams; in Next.js, useSearchParams + router.push; libraries like nuqs make it typed and easy.
Local state: useState, lifted when needed
Start with useState in the component that uses it. If a sibling needs it, lift it to the nearest common parent and pass it down. (useState explained) Passing props two or three levels is fine. It's explicit and easy to follow.
React Context: for low-frequency global values
Context passes a value deep into the tree without props:
const ThemeContext = createContext<'light' | 'dark'>('light')
<ThemeContext.Provider value={theme}>
<App />
</ThemeContext.Provider>
// anywhere below
const theme = useContext(ThemeContext)
Good for values that change rarely: theme, locale, the current user, feature flags.
The catch: every component that reads a context re-renders when its value changes. Put a frequently changing object in one big context and much of your app re-renders on every keystroke. Split contexts by concern and memoise values — or use a store.
Zustand: a small store with selectors
When global state changes often or is read in many places, a store with selectors re-renders only what's affected:
import { create } from 'zustand'
type CartState = {
items: CartItem[]
add: (item: CartItem) => void
remove: (id: string) => void
}
export const useCart = create<CartState>()((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
remove: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
}))
// only re-renders when the count changes
const count = useCart((s) => s.items.length)
No provider, tiny API, middleware for persistence (persist to localStorage) and devtools. A popular default for small and medium apps.
Redux Toolkit: structure for large teams
Redux (via Redux Toolkit, the modern way to write it) gives you a single store, strict update patterns, excellent devtools with time-travel, and conventions that scale across large teams. RTK Query adds server-state caching. It's more ceremony than Zustand — worth it when many developers share complex client state and you value predictability.
Jotai and atoms
Jotai (and similar atom libraries) model state as small independent pieces ("atoms") that components subscribe to individually. Great for highly interactive UIs — editors, canvases — with many independent bits of state.
Choosing
| Situation | Use |
|---|---|
| Data from your API | TanStack Query / Server Components |
| Filters, tabs, pagination | URL |
| Forms | React Hook Form (or local state) |
| Theme, locale, current user | Context |
| Cart, wizard, app-wide UI state that changes often | Zustand |
| Large team, complex shared client state | Redux Toolkit |
| Many independent fine-grained pieces | Jotai |
Common mistakes (AI-generated apps included)
- Putting everything in one global store "just in case."
- Copying API data into a store, then fighting stale data.
- One giant Context with frequently-changing values → slow app.
- Storing derived values (totals, filtered lists) instead of computing them.
- Mixing several state libraries for the same job. Pick one per job and write it in your CLAUDE.md.
The summary
- Classify state: server, URL, form, local, global.
- Server data → query cache; filters → URL; forms → form library; local →
useState. - Global client state: Context for rare changes, Zustand for frequent ones, Redux Toolkit for large teams.
- Less global state means fewer bugs.
EasySpawn gives Claude Code your running React app on a persistent server, so refactors like moving server data out of a global store can be done and checked in a live preview. See how it works or join the waitlist.
Related: useState Explained · TanStack Query vs useEffect · What Is React? · React Server Components Explained
Keep reading
TanStack Query vs useEffect for Data Fetching in React
Fetching in useEffect looks simple until you need loading states, errors, caching, race conditions, refetching and mutations. What TanStack Query handles for you, side-by-side code, mutations with invalidation, and when server components or plain useEffect are still the right choice.
React Server Components Explained: "use client", "use server", and What Runs Where
Server Components render on the server and send no JavaScript; Client Components hydrate in the browser for interactivity. How the boundary works, what 'use client' and 'use server' actually mean, passing data across, common errors, and how to keep secrets and bundles where they belong.