How do you decide between using React state, context, and external state managers?
TL;DR
Match the tool to the kind of state. Use useState/useReducer for local component state, and lift state up before reaching for anything heavier. Context transports a value through a subtree; it does not provide storage, update logic, or fine-grained subscriptions by itself. Reach for a client-state library such as Zustand, Jotai, or Redux Toolkit when many unrelated components need shared state and selector-based subscriptions or store tooling. Treat server state separately: a framework data layer or a library such as TanStack Query, SWR, or RTK Query can add caching, refetching, and invalidation when the application needs them.
Deciding between React state, context, and external state managers
Choose the smallest state mechanism that matches the value's ownership, update frequency, subscriber shape, and persistence needs.
React state (useState and useReducer)
React's built-in hooks cover the majority of real-world state needs. Start here and only escalate when you have a concrete problem to solve.
- Use
useStatefor simple, independent values (a toggle, an input value, a counter). - Use
useReducerwhen several state fields update together, when the next state depends on the previous state in complex ways, or when you want all transitions described in one place. It also pairs well with Context for app-scoped state that doesn't change often.
When to use React state
Keep state local when its consumers and update logic remain within one cohesive part of the tree.
- The state is only relevant to one component or a small subtree
- The state can be lifted to the closest common ancestor without prop-drilling getting painful
- You don't need cross-tree access, persistence, or devtools
Example
This counter needs no sharing mechanism because the state belongs to one component:
import { useState, useReducer } from 'react';function Counter() {const [count, setCount] = useState(0);return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>;}function reducer(state, action) {switch (action.type) {case 'add':return { items: [...state.items, action.item] };case 'remove':return { items: state.items.filter((i) => i.id !== action.id) };default:return state;}}function Cart() {const [state, dispatch] = useReducer(reducer, {items: [{ id: 'book', name: 'Book' }],});return (<ul>{state.items.map((item) => (<li key={item.id}>{item.name}{' '}<button onClick={() => dispatch({ type: 'remove', id: item.id })}>Remove</button></li>))}</ul>);}
React context
Context is a value-transport mechanism rather than a complete state-management solution. It lets descendants read a value without prop-drilling, but it does not supply storage or update logic. Consumers subscribed to a context re-render when the closest provider receives a different value according to Object.is; components that do not read that context are not updated for that reason. Putting unrelated, frequently changing values into one context can therefore cause unnecessary consumer renders.
Context works well for values such as theme, locale, current user, and feature flags. Pairing it with useState or useReducer can form a perfectly reasonable app-level state solution. If updates are frequent and different consumers need independent slices, split contexts or consider an external store with selectors.
In React 19, you can call Provider directly on the context (<ThemeContext> instead of <ThemeContext.Provider>) and read a context conditionally with the new use(Context) API:
import { createContext, use, useMemo, useState } from 'react';const ThemeContext = createContext(null);function ThemeProvider({ children }) {const [theme, setTheme] = useState('light');const value = useMemo(() => ({ theme, setTheme }), [theme]);// React 19: <Context> works as a Provider directlyreturn <ThemeContext value={value}>{children}</ThemeContext>;}function ThemedButton({ enabled }) {if (!enabled) return null;// `use` can be called conditionally; `useContext` cannotconst { theme, setTheme } = use(ThemeContext);return (<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>Theme: {theme}</button>);}
When to use Context
Context fits values that many descendants need and that can tolerate its broadcast-style subscription behavior.
- Passing rarely-changing values (theme, locale, current user, router) deep into the tree
- Avoiding prop-drilling for a value the whole subtree needs
- Combined with
useReducerfor app-scoped settings
When not to use Context
Avoid one broad context when updates are frequent or consumers need independent subscriptions.
- High-frequency updates (form input on every keystroke, animation state)
- Independent slices that should not re-render each other — split into multiple contexts or use a store with selectors
External client-state libraries
When many unrelated components need to read and write the same frequently-changing client state, an external store can notify only subscribers whose selected slice changed. External stores can also provide middleware, devtools, and persistence.
- Zustand — a small hook-based store with selector subscriptions and no required provider.
- Jotai — an atomic, bottom-up model where each piece of state is an atom; an atom update notifies consumers that read the changed atom.
- Redux Toolkit (RTK) — the modern, batteries-included Redux. The legacy
createStorefromreduxis deprecated; useconfigureStore+createSlice. RTK includes Redux DevTools (which support time-travel debugging) and pairs with RTK Query for server state. - MobX — observable/reactive model, popular in some enterprise codebases.
- Recoil is no longer a current choice because its repository was archived in 2025.
When to use an external store
An external store becomes useful when subscription control or capabilities outside the component tree outweigh the added dependency.
- Many unrelated components share the same updates and Context causes too many re-renders
- You need devtools, middleware (logging, persistence, undo/redo), or strict action-based update flows
- State needs to live outside the React tree (e.g. accessed from a non-React module)
Example with Redux Toolkit
Redux Toolkit centralizes named transitions and exposes selector-based reads through React Redux:
// counterSlice.jsimport { createSlice } from '@reduxjs/toolkit';const counterSlice = createSlice({name: 'counter',initialState: { count: 0 },reducers: {increment: (state) => {state.count += 1; // RTK uses Immer, so direct mutation is fine},},});export const { increment } = counterSlice.actions;export default counterSlice.reducer;// store.jsimport { configureStore } from '@reduxjs/toolkit';import counterReducer from './counterSlice';export const store = configureStore({reducer: { counter: counterReducer },});// Counter.jsimport { useSelector, useDispatch } from 'react-redux';import { increment } from './counterSlice';function Counter() {const count = useSelector((state) => state.counter.count);const dispatch = useDispatch();return <button onClick={() => dispatch(increment())}>Count: {count}</button>;}// App.jsimport { Provider } from 'react-redux';import { store } from './store';function App() {return (<Provider store={store}><Counter /></Provider>);}
Example with Zustand
Zustand exposes a store Hook whose selector determines the slice a component subscribes to:
import { create } from 'zustand';const useCounter = create((set) => ({count: 0,increment: () => set((s) => ({ count: s.count + 1 })),}));function Counter() {const count = useCounter((s) => s.count);const increment = useCounter((s) => s.increment);return <button onClick={increment}>Count: {count}</button>;}
Server state vs client state
A critical modern decision axis: data fetched from a server is not ordinary client state. It has a cache, can go stale, needs revalidation, refetching on focus, deduplication, retries, and pagination. Hand-rolling all of this on top of useState or Redux is error-prone.
For server data that needs a client cache, consider a dedicated server-state library:
- TanStack Query (formerly React Query) — a widely used, framework-agnostic option.
- SWR — lightweight alternative from Vercel.
- RTK Query — built into Redux Toolkit, integrates with the same store.
Keeping cached server data separate from client-only state can simplify the client store and make invalidation rules explicit.
import { useQuery } from '@tanstack/react-query';function Profile({ id }) {const { data, isLoading, error } = useQuery({queryKey: ['user', id],queryFn: async () => {const response = await fetch(`/api/users/${id}`);if (!response.ok) throw new Error(`HTTP ${response.status}`);return response.json();},});if (isLoading) return <Spinner />;if (error) return <Error error={error} />;return <h1>{data.name}</h1>;}
Quick decision guide
The following table summarizes common starting points rather than hard requirements:
| Situation | Use |
|---|---|
| State used by one component | useState |
| Several related fields, complex transitions | useReducer |
| Same value needed deep in the tree, changes rarely | Context (+ useReducer if needed) |
| Frequently-changing client state shared widely | Zustand / Jotai / Redux Toolkit |
| Data fetched from a server | TanStack Query / SWR / RTK Query |
| Form state | React Hook Form / TanStack Form |
| URL-driven state (filters, tabs) | Router search params (e.g. TanStack Router, Next.js) |
Further reading
- React Docs: state
- React Docs: passing data deeply with context
- React Docs:
use - Redux Toolkit
- Zustand
- Jotai
- TanStack Query
- SWR