Blog
4 min read

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