Context & Reducer
When to reach for context. useReducer for complex state.
Two hooks that solve different headaches. useContext kills prop drilling, the pain of threading a value through ten components so that the deepest one can read it. useReducertames complex state where multiple fields update together according to actions you can name. They're often used together, and together they're the React equivalent of a mini Redux without the boilerplate.
The prop drilling problem
Suppose your theme is light or dark, and every leaf component needs to know. Without context:
<App theme={theme}>
<Layout theme={theme}>
<Sidebar theme={theme}>
<Menu theme={theme}>
<Button theme={theme}>Click me</Button>Every component in the chain has to know about theme just to pass it down. Adding a second "global" value doubles the noise.
useContext: a wormhole through the tree
Context lets you publish a value at the top and read it anywhere below, no matter how deep. Three steps:
// 1. Create the context (anywhere, usually a separate file)
const ThemeContext = createContext("light");
// 2. Provide a value somewhere up the tree
<ThemeContext value={theme}>
<App />
</ThemeContext>
// 3. Read it anywhere below
function Button() {
const theme = useContext(ThemeContext);
return <button className={theme}>Click</button>;
}Note: in React 19 you write <ThemeContext value={x}> directly. The old <ThemeContext.Provider> still works but is now legacy syntax.
useContextfor it re-renders. Pass primitive values when you can. If your context holds an object that's recreated every render, you'll re-render the world. Wrap with useMemo or split into multiple contexts (one for state, one for setters).When NOT to use context
Context is not a state manager. It's a transport mechanism. Reach for it when:
- The value is genuinely "global-ish": theme, locale, current user, feature flags.
- You're tired of threading the same prop through ten layers.
Don't use it for:
- Server data. That belongs in TanStack Query, SWR, or similar.
- High-frequency updates (mouse position, scroll). Every consumer renders. You'll murder performance.
- A prop that two siblings share but the rest of the tree doesn't care about. Lift state up instead.
useReducer: when state has structure
useState shines for one or two values. Once your state is an object with several fields that update in coordinated ways (add item, mark done, set filter), you start writing the same setState logic in five different handlers. That's the signal to reach for useReducer.
const initial = { count: 0, step: 1 };
function reducer(state, action) {
switch (action.type) {
case "increment": return { ...state, count: state.count + state.step };
case "decrement": return { ...state, count: state.count - state.step };
case "set_step": return { ...state, step: action.value };
case "reset": return initial;
default: throw new Error("unknown action: " + action.type);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initial);
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
</div>
);
}The reducer is a pure function: (state, action) => newState. Predictable, testable, easy to log. dispatch is stable across renders, which helps with memoization too.
Discriminated unions in TypeScript
TypeScript shines here. Type your actions as a union of object shapes, and inside the switch, TS narrows the type for you.
type Action =
| { type: "add"; text: string }
| { type: "toggle"; id: number }
| { type: "remove"; id: number };
type State = { todos: Todo[] };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "add":
// TS knows action.text exists here
return { todos: [...state.todos, { id: Date.now(), text: action.text }] };
case "toggle":
// TS knows action.id exists, but NOT action.text
return { todos: state.todos.map((t) =>
t.id === action.id ? { ...t, done: !t.done } : t
)};
case "remove":
return { todos: state.todos.filter((t) => t.id !== action.id) };
}
}Pair this with a const dispatch = useReducer(...) and TypeScript will catch typos in your action types at compile time.
Combining them: context + reducer
Put a reducer at the top of your app. Expose its state and dispatch via context. Every component below can both read the state and requestchanges by dispatching. That's essentially Redux without the library, and for most apps it's plenty.
Try it: theme context + reducer
Click the buttons. Both the toolbar and the message read the same theme. Try adding a third theme "sepia" to the reducer.
Quiz
When is context the wrong tool?
Recap
useContextreads a value provided higher in the tree. Great for theme, locale, user, app-wide settings.- Every consumer re-renders when the value changes. Memoize the value or split into smaller contexts to control this.
- Context is not a state manager. Don't put server data or high-frequency state in it.
useReducershines when state has multiple coordinated fields.(state, action) => newState.- Discriminated union action types let TypeScript narrow per case.
- Context + reducer is the "Redux without Redux" pattern. Often all you need.