Frontend

React State: Choosing Where Data Should Live

Most React state problems are placement problems. The five places state can live, how to tell which one you need, and why server data does not belong in a global store.

Nested scopes of state from local component data out to the URL

The short version

  • Ask where state should live before asking which library manages it. Placement is the decision; the library follows.
  • Server state is not client state. Cached remote data has caching problems, not state management problems, and needs a tool built for that.
  • Once server state moves to a query library, most applications need remarkably little global state.
  • The URL is underused. Anything a user should be able to bookmark, share or reload into belongs there.

“Which state management library should we use?” is the wrong first question, and answering it produces the applications where a global store holds four hundred keys and nobody can say which are still read.

The better question is where each piece of state belongs. Get that right and the library choice becomes small and mostly obvious.

The five places state can live

1. Local component state

useState or useReducer, inside the component that uses it. Whether a dropdown is open, what is currently typed in an input, which tab is selected.

This should be your default, and more state qualifies for it than most codebases assume. State that lives here is trivially easy to reason about, because the entire lifecycle is visible in one file.

2. Lifted state

When two siblings need the same value, move it to their nearest common parent and pass it down. Prop drilling gets a bad reputation it only partly deserves — through two or three levels it is explicit, traceable, and better than the alternatives. Through eight, it is a signal to move up a level.

3. Context

For values genuinely needed across a wide subtree that change rarely: theme, locale, the current user, feature flags.

The critical caveat is that Context is not a state manager and does not optimise re-renders. Every consumer re-renders when the value changes, regardless of which part of it they read. Putting frequently changing state in a Context near the root of the tree is a well-worn route to a sluggish application.

Two mitigations: split contexts by update frequency so slow-changing and fast-changing values do not share one, and keep the provider as low in the tree as the consumers allow.

4. A global store

Zustand, Jotai, Redux Toolkit. For client state that is genuinely global, changes often, and is read in many unrelated places.

Here is the observation worth sitting with: once server data is handled properly by a query library, the amount of genuinely global client state left over is usually small. A complex application might have a handful of things — sidebar collapsed, active workspace, a multi-step wizard’s draft, an undo stack.

If your global store is large, it is worth checking how much of it is cached server data that has been put in the wrong place.

5. The URL

The most underused location by a wide margin. Search terms, active filters, sort order, current page, which tab is open, which record a detail panel is showing.

Putting these in the URL gets you shareable links, working back and forward buttons, state that survives a reload, and something you can link to from an email. Users expect all of that and are quietly annoyed when it is missing. It also removes the state from your application entirely — the browser stores it for you.

The test. If a user might reasonably want to send this view to a colleague, its state belongs in the URL. Applying that one rule usually moves a surprising amount of state out of a global store and improves the product at the same time.

Server state is a different problem

This is the distinction that resolves most React state confusion.

Client state is owned by your application. You created it, you know when it changes, and it is correct by construction. Server state is a cached copy of data owned somewhere else. It can be stale the instant you receive it, it can be changed by someone else, and it needs to be refetched, invalidated and deduplicated.

Those are caching concerns, and a general-purpose state container solves none of them. Which is why applications that store API responses in Redux end up hand-writing a cache: loading flags, error flags, staleness tracking, refetch logic, race condition handling. All of it, badly, in every slice.

A query library — TanStack Query, SWR, RTK Query, or your framework’s built-in data layer — does this properly. What you get for free:

  • Deduplication, so ten components asking for the same data produce one request.
  • Caching with configurable staleness, and background refetch so the interface is never blocked by revalidation.
  • Loading and error states as first-class values rather than booleans you maintain.
  • Retry with backoff, and refetch on window focus and reconnect.
  • Optimistic updates with automatic rollback on failure.
  • Race condition handling, so a slow response from an old request cannot overwrite a fast one from a new request. This bug is subtle, common, and almost never handled correctly by hand.

Adopting a query library is the single highest-leverage change available to most React codebases with state problems.

Derived state should be derived

The most common avoidable bug: storing something that could be computed.

If you have a list of items and a filter string, do not store the filtered list. Compute it during render. Two pieces of state that must be kept in sync will eventually fall out of sync, and finding the code path that forgot to update the second one is unpleasant work.

The performance objection is almost always misplaced. Filtering an array of a few hundred items during render is genuinely free. Reach for useMemo when profiling shows a real cost, not preemptively — memoisation has its own overhead and adds a dependency array to keep correct.

Forms deserve their own answer

Forms are state-heavy in ways that reward a dedicated tool rather than general state management. Field values, touched and dirty flags, per-field validation, async validation, submission state, error mapping from the server.

React Hook Form and its equivalents handle all of this and, importantly, keep field state uncontrolled so typing in one field does not re-render the entire form. On a large form this is the difference between responsive and noticeably laggy.

Pair it with a schema validator such as Zod and define the shape once, deriving both the validation and the TypeScript types from it.

A decision flow

  1. Can it be computed from existing state? Do that — do not store it.
  2. Does it come from a server? Query library.
  3. Should it survive a reload or be shareable? URL.
  4. Is it used by one component? Local state.
  5. Is it used by a few nearby components? Lift it.
  6. Is it needed widely and changes rarely? Context.
  7. Only then: global store.

Working through this in order is what keeps the last item small.

A large global store is usually not a sign of a complex application. It is a sign that server state was put somewhere it does not belong.

Boolean Solutions experience with React state

We build React applications and get called in to untangle them, and the untangling is nearly always the same job: move server data out of the global store into a query library, move view state into the URL, and discover that what remains is small enough to be obvious.

That refactor is usually quicker than teams fear, because most of the code being deleted is hand-rolled caching that the query library replaces outright. If your React application has state problems and you want a second opinion on the shape of the fix, get in touch.

Further reading

Written by

Udit Mittal

Founder at Boolean Solutions. Twenty years of building and rescuing web, mobile and AI products for SaaS companies and startups — and writing down what actually worked.

Get in touch