Quick Answer

Start with local state. Use Context for rarely-changing shared values. Use a store library for frequently-changing shared state. And use a data-fetching library for server data, which is most of what people put in global stores.

The distinction that resolves most of this

Two fundamentally different things get called "state".

Client state is owned by your application. Is the sidebar open? What is typed in this form? Which tab is active? It is synchronous, it is yours, and nobody else can change it.

Server state is a cache of data that lives elsewhere. The list of students, the current user's profile, the order history. You do not own it; you have a copy that can be stale, that needs refetching, and that another user may have changed.

Treating the second as if it were the first is why state management gets complicated. Putting API data in Redux means you personally implement loading flags, error handling, caching, refetching, deduplication and invalidation — all of which a data library already does.

Most applications have far less genuine client state than they think.

Start local

const [isOpen, setIsOpen] = useState(false);

The default, and it covers more than people expect. State belongs in the component that uses it, or the nearest common parent of the components that use it.

The instinct to reach for a global store early usually comes from prop drilling — passing a value through five components that do not use it. Before adding a library, consider whether component composition solves it: passing children through, rather than passing data down, often removes the drilling entirely.

URL state is also underused. The current page, active filters, selected tab and search query frequently belong in the URL rather than in memory. That makes them shareable, bookmarkable and survivable across refresh, and the browser manages them for you.

Context: shared, rarely changing

const ThemeContext = createContext('light');

<ThemeContext.Provider value={theme}>
  <App />
</ThemeContext.Provider>

Context solves prop drilling for values needed in many places: theme, language, the authenticated user, feature flags.

Its limitation is performance-shaped: every consumer re-renders when the value changes, with no way to subscribe to part of it. For a theme that changes twice a session, irrelevant. For a value changing on every keystroke, it is a problem.

A common mitigation is splitting into several contexts so consumers subscribe only to what they need. Once you are doing that extensively, you are re-implementing a store, and a store library is the better answer.

Context is a dependency injection mechanism, not a state manager — it distributes a value, it does not manage updates.

Store libraries

When many components read and write the same frequently-changing state, a store is appropriate.

Redux Toolkit is the established choice — structured, excellent developer tools with time-travel debugging, and predictable in large teams. More ceremony than the alternatives, and that structure is the point when many people touch the code. See Redux explained simply.

Zustand is much smaller:

const useStore = create((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
}));

function Counter() {
  const count = useStore((s) => s.count);
}

No provider, no reducers, and components subscribe to a slice via the selector — so unrelated changes do not re-render them, which is precisely Context's weakness.

Others worth knowing by name: Jotai and Recoil take an atom-based approach, and MobX uses observable objects. All solve the same problem with different ergonomics.

Server state deserves its own tool

This is the recommendation that changes most codebases.

const { data, isLoading, error } = useQuery({
  queryKey: ['students'],
  queryFn: fetchStudents,
});

One hook replaces a store slice, a loading flag, an error flag, a fetch effect and manual cache invalidation. It also adds things people rarely build by hand: deduplicating simultaneous requests for the same data, refetching when the window regains focus, retrying failures, and keeping stale data visible while revalidating.

React Query and SWR do this for React; SvelteKit and Nuxt have their own equivalents; RTK Query is built into Redux Toolkit if you are already using it.

A practical rule for a new project: local state, plus a data-fetching library, plus Context for a few global values. That covers most applications entirely. Add a store only when you find genuine client state that many components change frequently — and notice how rarely that actually happens.

Frequently Asked Questions

What is the difference between client state and server state? Client state is owned by your app, such as whether a modal is open. Server state is a cache of data owned elsewhere, which can go stale and needs refetching. They need different tools.
Do I need Redux? Usually not. Most global state turns out to be cached server data, which a data-fetching library handles better. Redux suits genuinely shared, frequently-changing client state in larger teams.
Why is Context bad for frequently changing values? Every consumer re-renders when the value changes, with no way to subscribe to only part of it. That is fine for a theme and costly for something changing on every keystroke.
What should I use for a new React project? Local state, a data-fetching library such as React Query for server data, and Context for a few global values. Add a store library only when a real need appears.
Should some state live in the URL? Yes, more often than people do. Filters, the active tab, pagination and search queries in the URL become shareable, bookmarkable and survive a refresh.