Frontend

Frontend

State Management: Context vs. Redux vs. Zustand

Most state should never leave the component that owns it. A practical guide to knowing when it should, and which tool to reach for once it does.

8 min read
ReactState ManagementRedux

Ask five React developers when to reach for Redux and you'll get five different answers, but almost all of them skip a more important question: does this state need to leave the component at all? The most common state management mistake isn't picking the wrong library — it's reaching for a global one before checking whether the state was ever global to begin with.

Start local, and mean it

A form's input values, whether a dropdown is open, the currently hovered row in a table — none of this needs to exist outside the component that renders it. Keeping it in useState right where it's used means it's trivially easy to reason about, it disappears when the component unmounts, and it can't accidentally trigger re-renders anywhere else in the tree.

The moment two components genuinely need the same piece of state — say, a filter value that both a search box and a results list depend on — the standard move is to lift it up to their nearest common ancestor and pass it down as props. That's still local state; it just lives one or two levels higher. Only when the state is needed by components that are far apart in the tree, or by so many components that prop drilling becomes its own maintenance burden, is it worth reaching for something "global."

The default, restated

Local by default. Lift when two nearby components share it. Go global only when the state is genuinely needed across distant, unrelated parts of the UI — not because it feels more "proper" to centralize things early.

React Context: solving prop drilling, not state management

Context exists to answer one specific problem: a value that many components need, that rarely changes, and that would otherwise have to be threaded through every layer of the tree as a prop. Theme, the current authenticated user, locale — these are Context's home turf. You set the value once near the root and any descendant can read it directly with useContext, no matter how deep it sits.

tsx
const ThemeContext = createContext<Theme>("light");

function App() {
  const [theme, setTheme] = useState<Theme>("light");
  return (
    <ThemeContext.Provider value={theme}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

// Any consumer, no matter how deep, reads it directly —
// no "theme" prop passed through every layer in between.
function Toolbar() {
  const theme = useContext(ThemeContext);
  return <button className={theme}>Save</button>;
}

The pitfall shows up as soon as the value changes often. Context has no concept of "this consumer only cares about part of the value" — when the value passed to a provider changes, every component consuming that context re-renders, even one that only reads a single field of a large object. Put a frequently-changing value — mouse position, a websocket feed, form state on every keystroke — behind a Context, and you've built a global re-render trigger. Context is a plumbing mechanism, not a state management system; it says nothing about how to update state efficiently, only how to make a value reachable without prop drilling.

Redux: ceremony that pays for itself at scale

Redux formalizes state changes into a strict pipeline: state lives in a single store, the only way to change it is to dispatch a plain object describing what happened (an action), and a reducer — a pure function — computes the next state from the current state and that action. Nothing mutates in place, every change is traceable, and because every action is just data, you can log them, replay them, or step backward and forward through them in the Redux DevTools.

That structure is exactly what a large team benefits from. When dozens of engineers touch the same codebase, a single, consistent pattern for "how state changes happen" is worth more than the boilerplate it costs — anyone can open an unfamiliar slice of the store and immediately know where to look for its actions, its reducer, and its selectors. Selectors also make Redux good at complex derived state: memoized selectors can recompute expensive aggregates only when the specific inputs they depend on change, which matters once your state graph gets large and interconnected.

On a small app, that same structure is mostly just cost. Writing action types, action creators, and a reducer for a single boolean flag is ceremony with no corresponding payoff — there's no large team to align, no tangled derived state to memoize, and no debugging session that actually needed time-travel to solve.

Zustand: a global store without the ceremony

Zustand (and similar lightweight stores) occupies the space Redux left underserved: state that's genuinely global, but doesn't need Redux's formal action/reducer pipeline to be manageable. You define a store as a plain function returning state and the functions that update it — no action types, no dispatch, no reducer switch statement.

tsx
const useCartStore = create<CartState>((set) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({ items: [...state.items, item] })),
  total: () => 0,
}));

// This component only re-renders when "items" changes —
// it never re-subscribes to the rest of the store.
function CartBadge() {
  const itemCount = useCartStore((state) => state.items.length);
  return <span>{itemCount}</span>;
}

The performance story is also better out of the box than Context's. Components subscribe to a slice of the store via a selector function, and Zustand only re-renders a component when the specific slice it selected actually changes — not whenever anything else in the store updates. A cart badge that reads only the item count won't re-render when the shipping address in the same store changes. That's the property Context can't give you without splitting into many separate contexts.

Same problem, less machinery

Zustand doesn't remove the need for discipline around how state changes — it just removes the mandatory ceremony Redux wraps around that discipline. For most apps that need global state but not an org-wide convention, that's the better trade.

A decision framework you can actually apply

In practice, the choice rarely needs to be agonized over if you work through it in order:

  • Local state, by default. If only one component (or two nearby ones after lifting) needs it, it stays in useState or useReducer right there.
  • Context, for rarely-changing shared values. Theme, auth session, locale, feature flags set once at load — values many components read but almost nothing writes to on a regular basis.
  • A lightweight store, for most global app state. Shopping carts, UI state shared across unrelated routes, cached data that several screens read and write — anything global that changes with normal user interaction belongs in something like Zustand rather than Context.
  • Redux, when the team and scale justify it. Large engineering organizations that need one enforced pattern across many contributors, or applications with deeply interdependent derived state that benefits from a serious selector and middleware ecosystem, get real value from Redux's structure — everyone else pays its cost without collecting the benefit.

None of these tools compete with each other so much as they cover different shapes of state. The mistake is almost never picking Redux over Zustand or vice versa — it's reaching for either one before asking whether the state needed to be global in the first place.

Key takeaways

  • Keep state local by default; lift it to a shared ancestor before reaching for anything global.
  • Context solves prop drilling for rarely-changing values — every consumer re-renders on any change, which makes it a poor fit for frequently-updating state.
  • Redux's action/reducer ceremony pays off with large teams, time-travel debugging, and complex derived state via selectors — and is overkill without those pressures.
  • Zustand-style stores give you global state with per-slice subscriptions and none of Redux's boilerplate.
  • Decision order: local state, then Context for stable shared values, then a lightweight store for most global state, then Redux when team scale demands its structure.
2026 — Built by Arpit Mishra