HomeProjectsBlogContact

React State Management: Choosing the Right Tool for Each Kind of State

Most state problems come from putting state in the wrong place, not from picking the wrong library. Local, shared, server, URL and form state, and the simplest tool for each one.

React State Management: Choosing the Right Tool for Each Kind of State

"Which state management library should I use?" is one of the most asked React questions, and it's usually the wrong question. Most apps that feel messy don't need a different library; they need state to live in the right place. Once you separate state by kind, the choice gets simple.

The five kinds of state

  1. Local UI state: is this dropdown open? What's typed in this input?
  2. Shared client state: the current theme, a shopping cart, a multi-step wizard.
  3. Server state: data that lives on the server (users, products, orders) and is cached in the browser.
  4. URL state: the current page, filters, search terms, the selected tab.
  5. Form state: values, validation errors, touched fields, submission status.

Each one has a natural home.

Local state: useState and useReducer

Keep state as close as possible to where it's used.

function Accordion({ title, children }) {
  const [open, setOpen] = useState(false)
  return (
    <section>
      <button onClick={() => setOpen(o => !o)} aria-expanded={open}>{title}</button>
      {open && children}
    </section>
  )
}

When state has several related pieces and transitions, useReducer keeps the logic in one place.

Don't store values you can calculate. If you have items, you don't need separate itemCount state; compute it during render. Duplicated state drifts out of sync. (The effect-loop bugs this causes.)

Shared state: lift it, then use context

When two components need the same state, lift it to their closest common parent and pass it down as props. That's often enough.

When props would travel through many layers, use context:

const ThemeContext = createContext('light')

function App() {
  const [theme, setTheme] = useState('light')
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Layout />
    </ThemeContext.Provider>
  )
}

Context is great for values that change rarely: theme, current user, language. Every consumer re-renders when the value changes, so for frequently changing state across many components, a dedicated store is better.

Global client stores: Zustand, Redux Toolkit and friends

Reach for a store when you have genuinely global client state that changes often or has complex update logic: a large cart, an editor's document, an offline queue.

  • Zustand: minimal API, very little boilerplate, components subscribe only to the slices they use.
  • Redux Toolkit: more structure, excellent dev tools, predictable patterns that suit large teams.
  • Jotai and similar atom-based libraries: small, composable pieces of state.

All are good. Pick based on team preference and app size, not hype.

Server state: use a data-fetching library

This is the big one. Data from your API isn't really your state; it's a cache of the server's state. It needs fetching, caching, deduplication, background refreshing, retries and invalidation after mutations. Building that by hand with useEffect and useState is where many apps get tangled.

Libraries like TanStack Query and SWR handle it:

const { data: orders, isLoading, error } = useQuery({
  queryKey: ['orders', userId],
  queryFn: () => fetchOrders(userId),
})

After moving server data into a library like this, many teams find they barely need a global store at all. In frameworks with Server Components, a lot of data fetching moves to the server entirely. (Rendering strategies.)

URL state: the underrated option

Filters, search queries, pagination, sorting and selected tabs belong in the URL:

/products?category=shoes&sort=price&page=2

Benefits: links can be shared and bookmarked, the back button works, refreshing keeps the view, and search engines can index meaningful pages. Read and write it with your router's search-params hooks.

Form state: use a form library for complex forms

Simple forms are fine with useState. For larger forms with validation, dynamic fields and error messages, a library like React Hook Form reduces re-renders and boilerplate, and pairs well with schema validation. (Validation done right.)

A decision table

State Where it should live
Open/closed, hover, input text useState in the component
Shared by a few nearby components Lift to the common parent
Theme, user, locale Context
Frequently changing global client state Zustand, Redux Toolkit or similar
Data from the API TanStack Query, SWR, or server-side fetching
Filters, search, pagination The URL
Complex forms A form library

Signs your state is in the wrong place

  • The same data stored in two places that disagree
  • Effects whose only job is to copy one state into another
  • Every keystroke re-rendering the entire page
  • A global store full of API responses and manual isLoading flags
  • Refreshing the page loses the filters the user just chose

FAQ

Do I need Redux for React?

Usually not. Many apps manage well with local state, context, a data-fetching library for server state and the URL. Redux Toolkit is a good choice for large apps with complex shared client state.

What is the difference between client state and server state?

Client state exists only in the browser, like whether a modal is open. Server state is data owned by the server and cached on the client, which needs fetching, caching and synchronisation.

When should I use React Context?

For values many components need that change infrequently, such as theme, locale or the current user. For frequently changing shared state, a dedicated store avoids unnecessary re-renders.

Keep reading

More from the workbench

All articles →