How can the React Context API be used for state management?
Context API can act as lightweight global state for rarely-updated values (auth, theme, locale). Combine with useReducer for action-based updates. Split contexts by update frequency to limit re-renders. Memoize the Provider value. NOT a substitute for Redux/Zustand when state updates often or many components subscribe — Context re-renders every consumer on every value change.
Context can do state management but has performance limits. Understand them before reaching for it.
Basic Context state
const ThemeContext = createContext<{ theme: 'light' | 'dark'; toggle: () => void } | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggle = useCallback(() => setTheme(t => t === 'light' ? 'dark' : 'light'), []);
const value = useMemo(() => ({ theme, toggle }), [theme, toggle]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme outside ThemeProvider');
return ctx;
}Context + useReducer pattern
For action-based updates:
type State = { user: User | null; cart: CartItem[] };
type Action =
| { type: 'login'; user: User }
| { type: 'logout' }
| { type: 'addToCart'; item: CartItem };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'login': return { ...state, user: action.user };
case 'logout': return { ...state, user: null };
case 'addToCart': return { ...state, cart: [...state.cart, action.item] };
}
}
const StateCtx = createContext<State | null>(null);
const DispatchCtx = createContext<React.Dispatch<Action> | null>(null);
function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, { user: null, cart: [] });
return (
<StateCtx.Provider value={state}>
<DispatchCtx.Provider value={dispatch}>{children}</DispatchCtx.Provider>
</StateCtx.Provider>
);
}Split state and dispatch into separate contexts so dispatch-only consumers don't re-render on state changes.
The re-render problem
Every consumer of a context re-renders when the value changes. With one big context:
<AppContext.Provider value={{ user, cart, theme, notifications }}>A notification arriving re-renders every component that uses any field. Even components that only read theme get re-rendered.
Fixes
1. Split contexts.
<UserContext.Provider value={user}>
<CartContext.Provider value={cart}>
<NotificationsContext.Provider value={notifications}>
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
</NotificationsContext.Provider>
</CartContext.Provider>
</UserContext.Provider>2. use-context-selector library.
const userName = useContextSelector(AppContext, c => c.user?.name);Re-renders only when the selected slice changes.
3. Switch to a real state library when context isn't enough.
When Context is enough
- Auth / current user: read by many, changes rarely (login/logout).
- Theme / locale / feature flags: read everywhere, almost never change.
- Dispatch only: stable identity, no re-render concerns.
- Compound component config: shared between Tab + TabList children.
When Context is NOT enough
- State updates frequently (mouse position, scroll, form per-keystroke).
- Many components subscribe and re-render is expensive.
- You need selectors to limit subscription scope.
- You need middleware (logging, devtools, persistence).
In those cases: Zustand, Jotai, Redux Toolkit.
Common mistakes
- No useMemo on value — new object every render, every consumer re-renders.
- One mega-context — every change cascades.
- Reading multiple unrelated values from one context — bind your re-renders to the most volatile field.
- Treating Context as Redux replacement without acknowledging the re-render cost.
Comparison
| Tool | Best for |
|---|---|
| Context | Rarely changing, broadly read state (auth, theme) |
| Context + useReducer | Action-based local state for a feature |
| Zustand | Frequent updates + selector subscriptions |
| Jotai | Atomic state with fine-grained reactivity |
| Redux Toolkit | Large apps with devtools, middleware, async patterns |
| React Query | Server state |
Senior framing
Context is for dependency injection of values that change rarely. It's not 'free Redux'. When you find yourself optimizing context re-renders with split providers and selectors, you've outgrown it — reach for a real store.
Follow-up questions
- •Why does memoizing the Provider value matter?
- •How would you handle frequent updates with Context?
- •What's the use-context-selector library and why does it exist?
Common mistakes
- •Fresh object/function as Provider value each render.
- •One mega-context — every update cascades.
- •Treating Context as a free Redux replacement.
Performance considerations
- •Every consumer re-renders on value change. Memoize value with useMemo. Split contexts. For high-frequency updates, use selectors (use-context-selector) or switch to a state library.
Edge cases
- •useContext bypasses React.memo — consumers re-render on value change regardless.
- •Default context value used only when no Provider is found above.
- •Multiple Providers for the same context: nearest wins.
Real-world examples
- •Every React app uses Context for auth, theme, router. State libraries like Redux use a single root Context to expose the store, but the store itself manages selective subscriptions internally.