React Context vs Redux — when each one makes sense
Context is React's built-in mechanism for "passing data deeply without prop drilling." Redux (or Zustand, Jotai, Recoil) is a state-management library with much more — middleware, time-travel debugging, devtools, derived selectors.
They're not direct substitutes. They solve overlapping but different problems.
Context — built into React
const ThemeContext = createContext<'light' | 'dark'>('light');
function App() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
return (
<ThemeContext.Provider value={theme}>
<Header />
<Body />
</ThemeContext.Provider>
);
}
function Header() {
const theme = useContext(ThemeContext);
return <header className={theme}>...</header>;
}
Great for rarely-changing, broadly-needed values — current user, theme, locale, feature flags.
Where Context breaks down
const StoreContext = createContext({ count: 0, items: [], cart: null });
// ... 50 components useContext(StoreContext)
When the context value changes, every consumer re-renders, even if it only cared about one field. With heavy state + many consumers, this becomes a perf problem.
You can mitigate with multiple smaller contexts or useMemo on the value, but it gets clunky.
Redux (and modern equivalents) — selective subscriptions
// store.ts
import { configureStore, createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
addItem: (state, action) => { state.items.push(action.payload); },
removeItem: (state, action) => {
state.items = state.items.filter((i) => i.id !== action.payload);
},
},
});
export const store = configureStore({ reducer: { cart: cartSlice.reducer } });
export const { addItem, removeItem } = cartSlice.actions;
// Component
function CartCount() {
const count = useSelector((s) => s.cart.items.length); // re-renders ONLY when length changes
return <span>{count}</span>;
}
useSelector subscribes to a slice. Other state changes don't re-render the component.
Decision matrix
| Need | Use |
|---|---|
| Pass user/theme/locale deep | Context |
| State shared by many components, perf-sensitive | Redux / Zustand |
| Async data fetching + caching | TanStack Query (NOT Redux) |
| Form state | react-hook-form |
| Server state (API responses) | TanStack Query or SWR |
| Client state, large scale | Zustand or Redux Toolkit |
My actual advice for new apps in 2026
- TanStack Query for server state — covers most "what was Redux for" use cases.
- Zustand for client state — 10× simpler than Redux, similar perf.
- Context only for static-ish values (theme, user, locale).
- Redux Toolkit if you specifically need its tooling (time-travel, middleware ecosystem) or join an existing Redux codebase.
Why "don't put server state in Redux"
// ❌ Manual loading state, manual revalidation, manual cache
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetch('/api/products').then((r) => r.json()).then((d) => {
dispatch(setProducts(d));
setLoading(false);
});
}, []);
vs
// ✅ TanStack Query handles cache, dedup, retry, refetch, loading state
const { data, isLoading } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then((r) => r.json()),
});
Redux became the default before TanStack Query existed. Today, "fetch + cache" is a solved problem outside Redux.
One-liner
- Context = built-in DI for values
- Redux = state container with tooling, for client-only state
- TanStack Query = the right thing for "data from the server"