Most React state bugs and performance complaints trace back to a single earlier decision: putting a piece of state in the wrong place. A value that should have lived in one component ends up in a global store. Server data gets cached by hand in useState and quietly drifts out of sync with the backend. A theme flag in a wide Context re-renders half the tree on every toggle. None of these are framework problems — they're placement problems.
The takeaway up front: the useful question is never "which state library should I use?" It's "what kind of state is this, and how far does it need to travel?" Answer that and the tool picks itself. Most state stays local, a little gets shared by lifting it up, a narrow slice belongs in Context, and only genuinely app-wide client state justifies an external store. Server data is its own category with its own tools. Reach for a library because a specific problem forces you to, not by default.
First, classify the state
Before choosing a mechanism, sort every piece of state into one of four kinds. The kind determines the tool far more than the size of your app does.
- Local UI state — a modal's open/closed flag, an input's current value, a toggle. Used by one component and nothing else.
- Shared client state — data owned by the client that several components read or write: the current theme, the logged-in user object, items in a cart, a multi-step form's progress.
- Server state — a cache of data that actually lives on the server: a user profile, a list of orders, search results. You don't own it; you're mirroring it.
- URL state — the current route, query params, filters encoded in the address bar. The URL is already a store; treat it like one.
The single most common mistake is treating server state as if it were client state — hand-rolling loading flags and caches in useState when the data really belongs to the backend. We'll come back to that, because it's where most "we need Redux" conversations actually go wrong.
Start local: useState and useReducer
Local state is the default, and most state should stay here. If a value is only read and written inside one component, useState is the whole answer.
function SearchBox() {
const [query, setQuery] = useState("");
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
/>
);
}
Reach for useReducer when the transitions get complex — when the next state depends on the previous one in more than a trivial way, or when several actions mutate the same object. A reducer centralizes those transitions in one testable function instead of scattering setState calls across handlers.
function cartReducer(state, action) {
switch (action.type) {
case "add":
return { ...state, items: [...state.items, action.item] };
case "remove":
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
case "clear":
return { ...state, items: [] };
default:
return state;
}
}
function Cart() {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
// dispatch({ type: "add", item }) from any handler below
}
The trade-off is boilerplate: a reducer is more code than a useState for a boolean. Use it when the extra structure buys you clarity, not reflexively. A good signal is "I have three or four useState calls that always change together" — that's a reducer waiting to happen.
Share by lifting state up before reaching for anything global
When two sibling components need the same value, the first move is not a store — it's moving the state to their closest common parent and passing it down as props. This is "lifting state up," and it resolves a large share of sharing needs with zero new dependencies.
function SearchPage() {
const [filter, setFilter] = useState("all");
return (
<>
<FilterPanel filter={filter} onChange={setFilter} />
<ResultsList filter={filter} />
</>
);
}
Lifting works cleanly when the components are close together in the tree. It starts to hurt when the value has to travel through many intermediate components that don't care about it — "prop drilling." One or two levels of drilling is fine and honest; five levels of threading a prop through components that just forward it is a smell. That smell is the signal to consider Context or a store — but only that far up the tree, and only for that value.
Context: for values read widely but changed rarely
Context solves prop drilling by making a value available to any descendant without passing it through every level. It's the right tool for state that is read in many places but changes infrequently: theme, locale, the current user, feature flags.
const ThemeContext = createContext("light");
function App() {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Layout />
</ThemeContext.Provider>
);
}
function ThemeToggle() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
{theme}
</button>
);
}
Here is the trap. Every component that consumes a context re-renders whenever the provider's value changes, regardless of which part of that value it actually reads. For a theme that flips a few times per session, that's a non-issue. Put rapidly-changing state in a wide context — form input on every keystroke, a mouse position, a live timer — and you re-render every consumer on every change. Those wasted renders show up as sluggish interactions; if your interaction latency feels off, this is a common cause (see our guide to Core Web Vitals for how that gets measured).
Three practical mitigations, in the order you should try them:
- Split contexts by change frequency. Keep a stable, rarely-changing context separate from a volatile one, so a fast-changing value only re-renders the few components that read it.
- Memoize the provider value with
useMemoso it isn't a fresh object on every parent render, which would invalidate consumers needlessly. - Move to a store with selectors if you genuinely need many components subscribing to slices of frequently-changing shared state. That's precisely the problem external stores are built for.
The ordering reflects cost: splitting and memoizing are free and local; adding a store is a dependency and a new mental model, so it comes last.
When an external store earns its place
An external store — the Redux Toolkit / Zustand / Jotai family — is worth adding when you hit the specific problem Context handles badly: client state that is read and written by many components spread across the tree, and that changes often enough that broad re-renders matter. The feature that distinguishes a store from Context is the selector: a component subscribes to one slice of state and re-renders only when that slice changes, not when anything in the store changes.
// Zustand-style store: components subscribe to slices via selectors
const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
clear: () => set({ items: [] }),
}));
function CartCount() {
// Re-renders only when items.length changes, not on every store update
const count = useCartStore((s) => s.items.length);
return <span>{count}</span>;
}
These libraries optimize for different things, and the right pick follows from what your team values rather than a universal ranking:
- Redux Toolkit centralizes everything in a single, strictly-structured store with excellent devtools and time-travel debugging. The trade-off is more ceremony and a steeper on-ramp; it pays off on large teams and complex client state where a shared, inspectable convention is worth the overhead.
- Zustand is a small hook-based store with minimal boilerplate and selector subscriptions out of the box. The trade-off is fewer guardrails — the structure discipline is on you — which suits small teams that want a store without the ceremony.
- Jotai models state as many small atoms you compose, which fits UIs with lots of fine-grained, independent pieces of state. The trade-off is that "many atoms" can become its own thing to reason about at scale.
The honest default: don't add any of these until prop drilling and Context have actually failed you. A store is not a maturity milestone. Plenty of solid React apps never need one because most of their state is either local or, as we'll see next, server state in disguise.
Server state is not client state
This is the category that sends the most teams reaching for a global store they don't need. Data fetched from an API is a cache of something the server owns, not state your client authors. Managing it by hand means reimplementing caching, request de-duplication, background revalidation, and loading/error tracking — in useState — for every screen.
// The hand-rolled version you end up maintaining forever
function Profile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then((data) => !cancelled && setUser(data))
.catch((e) => !cancelled && setError(e))
.finally(() => !cancelled && setLoading(false));
return () => { cancelled = true; };
}, [userId]);
// ...and still no caching, dedup, or revalidation
}
A server-state library — React Query (TanStack Query) or SWR — collapses that to a declaration and handles the cache, dedup, and refetching for you:
function Profile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
});
if (isLoading) return <Spinner />;
if (error) return <ErrorBanner message={error.message} />;
return <h1>{data.name}</h1>;
}
The point that matters for state architecture: once server state lives in a dedicated cache, most of what was left in your "global store" turns out to be small. Separating the two is often what shrinks a sprawling client store down to a handful of genuinely client-owned values. Note that server rendering complicates the picture — hydration has to reconcile server-fetched data with the client cache — which is one more reason to keep server state in a tool built for it rather than hand-rolled (see our frontend rendering strategies guide for how rendering location interacts with data).
A decision checklist
Walk these in order and stop at the first match:
- Used by one component?
useState, oruseReducerif the transitions are complex. - Used by a few nearby components? Lift it to their common parent and pass props.
- Read widely but changes rarely (theme, locale, current user)? Context.
- Read and written widely, changes often, needs selective subscription? An external store.
- Is it really a copy of server data? A server-state library — and then re-check steps 1–4, because there's usually less client state left than you thought.
The whole checklist is just two variables — how far the state travels and how often it changes — applied consistently. That's the entire discipline.
FAQ
Do I need Redux (or any store) for a React app? No. Many production React apps never use one. Add a store only when client state is shared widely, changes often, and needs selector-based subscriptions to stay performant. Before that, local state, lifting up, and Context cover most needs.
Is Context a state manager?
Not exactly — it's a transport for state, not a store. Context distributes a value to descendants but doesn't optimize updates: every consumer re-renders when the value changes. It pairs with useState/useReducer (which hold the state); it doesn't replace a store's selector-based subscriptions for high-frequency updates.
useState or useReducer — how do I decide?
Use useState for independent, simple values. Switch to useReducer when the next state depends on the previous one in non-trivial ways, or when several useState calls always change together. The reducer trades a little boilerplate for centralized, testable transitions.
Does putting everything in one store hurt performance? Only if components subscribe to the whole store instead of slices. Stores that support selectors let each component re-render only when its slice changes. The performance problem people blame on "one big store" is usually missing or overly broad selectors, not the store itself.
Where should form state live? Usually local to the form, or in a dedicated form library that keeps per-field state and validation out of your global store. Form input changes on every keystroke, so putting it in a wide Context or a broadly-subscribed store is a classic source of unnecessary re-renders.
Put the classification into practice
Pick one screen in your app and label every piece of state on it: local, shared client, server, or URL. You'll almost always find something in the wrong place — server data cached by hand, or a value in a global store that only one component reads. Move each piece to the tool its category calls for, and both the bugs and the excess re-renders tend to disappear.
When you're wiring up the data-fetching side of that work, grab the copy-paste starting points instead of rebuilding them from scratch — see the runnable React snippets on TheAppCode for tasks like making a GET request, handling the loading and error states, and parsing the JSON response.