Choosing a State Management Approach
- react
- javascript
- typescript
- webdev
- frontend
- statemanagement
- softwarearchitecture
In Form Validation with Zod, we spent a lot of effort keeping form state predictable: one schema, one source of truth for what "valid" means, and a clear boundary between what the form owns and what the server owns. That boundary is really a special case of a bigger question every React app eventually forces you to answer: where does this piece of state actually live, and what tool should own it.
This article is part of the React & Next.js Complete Guide, a series that runs from fundamentals through hooks, routing, and production patterns. State management sits right in the middle of that arc, because almost every other decision, how you fetch data, how you structure components, how you test things, depends on getting this one right first.
I want to be upfront about the bias in this article: I think most teams reach for a state management library too early, and I think Context gets used for jobs it's badly suited to. Neither of those is a controversial opinion anymore, but the reasoning behind them is worth walking through properly, because "just use Zustand" or "just use Context" are both bad defaults if you skip the reasoning.
Start by naming the kind of state you actually have
The single biggest mistake I see in state management decisions is treating "state" as one category. It isn't. In a typical SaaS app, you're juggling at least four distinct kinds, and each one has a different natural home:
- Local UI state: is this dropdown open, which tab is active, what's currently typed into a search box before it's submitted. This state doesn't need to survive a page refresh and nothing outside the component cares about it.
- Server state: the list of invoices, the current user's subscription, a project's task list. It's a cached copy of something the server owns, and it can go stale the moment another tab or another user changes it.
- URL state: the current page number, the selected filter, the open modal. If a user should be able to bookmark or share the exact view they're looking at, that value belongs in the URL, not in a
useStatecall. - Global client state: things that are genuinely client-only and genuinely cross-cutting, like theme preference, whether a global command palette is open, or an in-progress multi-step wizard that spans routes.
| Kind of state | Examples | Where it belongs |
|---|---|---|
| Local UI | Dropdown open, active tab, unsubmitted input | useState (or useReducer for lockstep transitions) |
| Server | Invoices, current user, task list | A data-fetching cache (e.g. React Query) |
| URL | Page number, active filter, open modal | The URL (search params) |
| Global client | Theme, command palette, cross-route draft | Context if it changes rarely; a store (Zustand) if often |
Most state management pain comes from putting state in the wrong bucket, not from picking the wrong library. Server state stuffed into a Redux store recreates cache invalidation problems that a data-fetching library already solved for you. UI state lifted into a global store adds re-render overhead for a value nothing outside one component tree needs. Get the categorization right first, and the tool choice for each category becomes close to obvious.
Local state is the default, and useReducer is underused
For local UI state, useState is correct almost all of the time and I'd start there without hesitation. The exception worth calling out is when several pieces of state change together as a single logical transition. That's where useReducer earns its keep, because it turns a pile of related useState calls into one function that describes every valid transition explicitly.
type WizardState = {
step: "details" | "billing" | "review";
details: { name: string; email: string } | null;
billing: { plan: string } | null;
submitting: boolean;
error: string | null;
};
type WizardAction =
| { type: "SUBMIT_DETAILS"; payload: { name: string; email: string } }
| { type: "SUBMIT_BILLING"; payload: { plan: string } }
| { type: "GO_BACK" }
| { type: "SUBMIT_START" }
| { type: "SUBMIT_ERROR"; payload: string };
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case "SUBMIT_DETAILS":
return { ...state, step: "billing", details: action.payload };
case "SUBMIT_BILLING":
return { ...state, step: "review", billing: action.payload };
case "GO_BACK":
return { ...state, step: state.step === "review" ? "billing" : "details" };
case "SUBMIT_START":
return { ...state, submitting: true, error: null };
case "SUBMIT_ERROR":
return { ...state, submitting: false, error: action.payload };
default:
return state;
}
}
The reason this beats four separate useState calls is correctness, not style. With separate state variables, it's possible to end up in an invalid combination, submitting: true alongside a stale error from the previous attempt, because nothing enforces that they change together. The reducer makes every valid transition a named case and every invalid one simply doesn't exist as a code path. For a multi-step form, a drag-and-drop interaction, or anything with more than two or three states that move in lockstep, that guarantee is worth the extra boilerplate.
Context solves prop drilling, not state management
Context gets reached for constantly as a general state management solution, and that's where teams get burned. Context was designed to solve one specific problem: passing a value down through many layers of components without threading it through every intermediate prop. It was never designed to be an efficient way to distribute frequently-changing state.
The mechanism matters here. When a context value changes, every component that calls useContext for that context re-renders, regardless of whether the part of the value it actually reads changed. Put your entire app's state, current user, cart contents, notification count, theme, into one context object, and updating any single field re-renders every consumer of that context.
// This re-renders every consumer whenever anything in the object changes,
// even a component that only reads `theme`.
const AppContext = createContext<{
user: User | null;
cart: CartItem[];
theme: "light" | "dark";
} | null>(null);
The fix is to split Context by how often each slice changes and who actually needs it, rather than avoiding it altogether.
const ThemeContext = createContext<"light" | "dark">("light");
const UserContext = createContext<User | null>(null);
A component that reads ThemeContext now only re-renders when the theme actually changes, not when the cart updates. This is a real tradeoff, not a free lunch: more contexts mean more providers wrapping your tree, and more places to remember to update when a new global value shows up. I'd still take that over one context object that silently re-renders half the app on every keystroke in a search box. Context is a good fit for values that change rarely: theme, locale, an authenticated user object, feature flags loaded once at boot. It's a poor fit for anything that updates on every render cycle of user interaction.
Server state is not client state, and treating it as such is the most common mistake
If your app fetches data from an API, that data is a cache of something else's source of truth, not state you own. Caches have their own concerns: staleness, refetching, deduplication, background updates, and invalidation when a mutation changes the underlying data. Reimplementing all of that manually with useEffect and useState is the pattern this series covers in Data Fetching with React Query. Understanding why a dedicated library exists for this, rather than folding server data into a general-purpose store, matters more than it might seem at first.
A Redux or Zustand store has no built-in concept of "this data might be stale" or "refetch this in the background while showing the cached version." You'd end up hand-rolling loading flags, error flags, refetch logic, and cache keys, all of which a data-fetching library already does correctly, including edge cases like request deduplication across components that ask for the same data simultaneously. The practical rule: if a value came from a fetch call, it belongs in a server-state cache, not in your global client store. Keep the two concerns separate even when it's tempting to put everything in one place for convenience.
When a global client store actually earns its place
After ruling out local state, Context, and server-state caching, what's left is a genuinely small category: client-only state that multiple, unrelated parts of the app need to read or write, and that changes often enough that Context's re-render behavior would hurt. A shopping cart that's shown in a header badge and a checkout page. A command palette's open/closed state, toggled from a keyboard shortcut anywhere in the app. An in-progress draft that survives navigation between routes.
This is the category the next article in this series, Zustand for Client State, covers in depth. The short version of why a dedicated store like Zustand beats Context for this case: it lets components subscribe to a narrow slice of state and only re-render when that slice changes, without you manually splitting providers into a dozen contexts. That selective-subscription model is the actual technical reason to reach for a store, not "Redux is what serious apps use." If you can count the app-wide client-only values you need on one hand, and they change rarely, Context genuinely is enough. If that list starts to grow, or those values update frequently, that's the signal to introduce a proper store rather than a preemptive one.
Production pitfalls worth naming directly
A few mistakes show up repeatedly once state management decisions ship to production. Putting derived state into useState instead of computing it during render is the most common: a filteredItems state variable that's set inside a useEffect watching the source list will always be one render behind, and it adds a synchronization bug that a plain calculation wouldn't have. Storing server data in global client state without a TTL or invalidation strategy is the second: a cart that never refetches after another tab checks out will show a stale count until the user reloads. The third is treating URL state as an afterthought: filters and pagination that live only in component state break the back button and can't be shared as a link, which for a SaaS product is a real usability regression, not a cosmetic one.
Key takeaways
- Identify the category of state first, local UI, server, URL, or global client, before picking a tool; most state management pain comes from the wrong bucket, not the wrong library.
- Default to `useState`; reach for `useReducer` when several values must change together as one valid transition, since it makes invalid combinations impossible rather than just unlikely.
- Context is built for infrequently-changing values passed through many layers, not as a general store; split it by how often each value changes to avoid re-rendering the whole tree on every update.
- Server data belongs in a dedicated caching layer like React Query, not in a general-purpose store, because staleness and invalidation are concerns a store doesn't handle out of the box.
- Filters, pagination, and open modals that a user should be able to bookmark or share belong in the URL, not in component state.
- Reach for a global client store only once you've ruled out the other three categories and the remaining state changes often enough that Context's re-render cost becomes a real problem.
Frequently asked questions
Should I use Redux, Zustand, or Context for a new React project?
Start with none of them. Use local state and, where genuinely needed, Context for rarely-changing global values. Introduce a dedicated store like Zustand only once you have client-only state that's shared across unrelated parts of the app and updates often enough that Context's re-render behavior becomes a measurable problem.
Why does my whole app re-render when only one context value changes?
Every component calling `useContext` for a given context re-renders when that context's value changes, even if the component only reads a field that didn't change. Split a single large context object into several smaller ones, grouped by how often each piece actually updates.
Is it ever correct to put API data in a global store like Zustand or Redux?
It can work for data that's fetched once and rarely changes, but for anything that needs refetching, background updates, or invalidation after a mutation, a dedicated data-fetching cache handles those concerns correctly out of the box. Mixing server data into a general client store usually means reimplementing that logic by hand.
How do I know when useReducer is a better fit than multiple useState calls?
When two or more pieces of state must always change together to remain valid, like a wizard's current step and the data collected in that step. A reducer expresses every valid transition as an explicit case, so invalid combinations can't happen; separate `useState` calls don't enforce that on their own.
Should pagination and filters live in component state or the URL?
In the URL, whenever a user might reasonably want to bookmark, share, or use the back button to return to a specific view. Component-only state for these values breaks that expectation and is a common source of "the back button doesn't work right" complaints.
Does using a state management library mean I don't need React Query or SWR?
No, and treating them as interchangeable is a common mistake. A client store manages state your app owns; a data-fetching library manages a cache of state the server owns. Most production apps use both, for different categories of state.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.