Topic 9.6
Scaling Up with a Reducer and Context
In one line
Combine a reducer (all the update logic) with context (reach anywhere): a provider component owns useReducer and puts state and dispatch in two separate contexts, and small custom hooks such as useTasks() and useTasksDispatch() let any component read or update the store without props.
Think of it like this
A library. The librarian keeps one register of every book and follows fixed rules for lending and returning (that's the reducer). The notice board at the entrance shows which books are available, and anyone in the building can read it (the state context). The request box next to it accepts slips from anyone, anywhere in the building (the dispatch context). Readers don't need to pass slips hand to hand, and nobody but the librarian writes in the register.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Store
- One place that holds a piece of app state and the rules for changing it.
- State context
- The context that carries the current state, read by components that display it.
- Dispatch context
- The context that carries
dispatch, read by components that send actions. - Provider component
- Your own component that owns
useReducerand renders the providers aroundchildren. - Custom hook
- Your own function whose name starts with
useand that calls other hooks, likeuseTasks(). - Guard hook
- A custom hook that throws a clear error when its provider is missing.
- Action hook
- A hook returning named functions (
addTask) that dispatch actions for you. - Selector
- A function that picks part of the state, so only components that use that part re-render (in store libraries).
Step by step
01Create two private contexts
Both start as null so the hooks can detect a missing provider. Don't export them; export the hooks instead.
import { createContext, useContext, useReducer } from "react";
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);02Write the provider component
It owns the reducer and provides both values. dispatch is stable, so the dispatch context never changes.
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
{children}
</TasksDispatchContext>
</TasksContext>
);
}03Add the custom hooks
Each hook reads one context and throws a helpful error when used outside the provider.
export function useTasks() {
const tasks = useContext(TasksContext);
if (tasks === null) throw new Error("useTasks must be used inside <TasksProvider>");
return tasks;
}
export function useTasksDispatch() {
const dispatch = useContext(TasksDispatchContext);
if (dispatch === null) throw new Error("useTasksDispatch must be used inside <TasksProvider>");
return dispatch;
}04Wrap the app and use the hooks anywhere
No component passes tasks or callbacks as props any more. Each one asks the store for exactly what it needs.
import { TasksProvider, useTasks, useTasksDispatch } from "./TasksContext";
export default function App() {
return (
<TasksProvider>
<AddTask /> {/* reads dispatch only */}
<TaskList /> {/* reads tasks and dispatch */}
</TasksProvider>
);
}05See the data flow
Actions travel up through the dispatch context to the reducer; the new state travels down through the state context to its readers. Components that only dispatch stay still.
06Grow it carefully
Add action hooks and derived hooks as the app grows. When different features appear, make a new store per feature instead of one giant reducer, and place each provider just above the components that need it.
Try it yourself
- 1
Predict who renders while typing
In the task store, type a letter in the New task box. Predict the console. (
AddTask renderedonly: the text isAddTask's own state, and nothing in the store changed.) - 2
Merge the contexts and compare
Change the provider to one context holding
{ tasks, dispatch }(memoised withuseMemo), and makeAddTaskread it. Tick a task. What new line appears? (AddTask rendered: it now reads a value that changes with every action.) - 3
Add a derived hook
In the task store, write
function useRemainingCount() { return useTasks().filter((t) => !t.done).length; }and use it inSummary. The component gets shorter, and other components can reuse the same calculation.
Code & diagrams
After the tick, only the two components that read the tasks render again. `AddTask` reads only `dispatch`, which never changes, and it isn't re-rendered by its parent either: `App` created it, and `App` didn't render. Type in the box: only `AddTask` renders, because the text is its own local state.
Preview
Console
Nothing logged yet.Expected console output
AddTask rendered
TaskList rendered: 2 tasks
Summary rendered
--- tick the second task
TaskList rendered: 2 tasks
Summary rendered
0 left`Product` never sees the cart; `Header` never builds an action object. Signing out clears the user and the cart in one action, so the header can't show a cart that belongs to nobody.
Preview
Console
Nothing logged yet.Expected console output
Hi, Asha · 3 items · ₹55This example fails on purpose. Without the check you'd get `Cannot read properties of null (reading 'length')`, which says nothing about the real mistake. The guard's message names the fix.
Preview
Console
Nothing logged yet.Expected console output
Uncaught Error: useTasks must be used inside <TasksProvider>
An error occurred in the <TaskCount> component.
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.// src/tasks/TasksContext.jsx — reducer, contexts, provider and hooks in one module
import { createContext, useContext, useReducer } from "react";
import { tasksReducer, initialTasks } from "./tasksReducer"; // pure, tested on its own
const TasksContext = createContext(null); // not exported
const TasksDispatchContext = createContext(null); // not exported
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>{children}</TasksDispatchContext>
</TasksContext>
);
}
export function useTasks() { /* guard + useContext(TasksContext) */ }
export function useTasksDispatch() { /* guard + useContext(TasksDispatchContext) */ }
// src/main.jsx
createRoot(document.getElementById("root")).render(
<StrictMode>
<TasksProvider>
<App />
</TasksProvider>
</StrictMode>
);
// src/components/AddTask.jsx
import { useTasksDispatch } from "../tasks/TasksContext";Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Using a store hook outside its provider
In the task store, render <Summary /> next to <TasksProvider> instead of inside it: <><TasksProvider>…</TasksProvider><Summary /></>.
Break #2
Exporting the raw context and skipping the hooks
In TaskList, replace the hooks with direct reads, useContext(TasksContext) and useContext(TasksDispatchContext); then render <TaskList /> without the provider (as a quick test might).
Myth vs fact
Myth
Reducer + context is a full replacement for Redux.
Fact
It covers the core idea (one store, actions, a reducer), but has no selectors, middleware, devtools or async helpers. Every state reader re-renders on every action.
Myth
You need useCallback or useMemo for the dispatch context.
Fact
dispatch from useReducer is already stable. Only objects you build around it, like an actions object, need useMemo.
Myth
All app state should go in one big store.
Fact
Separate stores per feature, placed as low as possible, keep updates small. Local UI state (an open menu, a text box) stays in its component.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Two contexts are the minimum split. For a store with very different readers, you can also split the state by domain (a
UserContextand aCartContextfed from one reducer) so cart changes don't re-render user-only readers. Past a few of those, a store with selectors is simpler (Topic 15.2). - ▸
The provider can persist state: initialise lazily from
localStoragewith the init function (useReducer(reducer, null, () => load() ?? initial)) and save in an effect when the state changes (Phase 7). Keep the reducer pure; persistence lives in the provider. - ▸
Async work (fetching, saving) doesn't go in the reducer. Do it in an event handler or an action hook (
async function saveTask(t) { dispatch({ type: "saving" }); await api.save(t); dispatch({ type: "saved" }); }). That's what Redux calls a thunk (Topic 15.4). - ▸
For TypeScript, the context default is
null, so the type isTasks | null; the guard hook narrows it toTasks, so every component gets a non-null type without!(Topic 12.5).
Remember this
- 1
Why combine them. A reducer (Topic 9.4) puts the update logic in one place, but
stateanddispatchstill live in one component and must be passed down as props. Context (Topic 9.2) can carry values to any depth, but it doesn't manage state. Together they give you a small app-wide store: one reducer holds the logic, one provider shares the state anddispatch, and any component can read or update without drilling. It's the same shape as Redux (one store, actions, a reducer), built from two React hooks. - 2
Two contexts: state and dispatch. Put
tasksin aTasksContextanddispatchin a separateTasksDispatchContext. The state changes on every action;dispatchnever changes (React keeps its identity stable). A component that only sends actions, like an "Add task" form, reads only the dispatch context, so it doesn't re-render when the list changes. That's the split from Topic 9.3, and with a reducer you get it for free, with nouseCallbackoruseMemo. - 3
A provider component. Wrap the reducer and both providers in one component,
TasksProvider({ children }). It ownsuseReducer, renders the two providers, and renderschildreninside. The rest of the app just writes<TasksProvider><App /></TasksProvider>. When the state changes,TasksProviderre-renders, but thechildrenelements were created by its parent, so only the components that readTasksContextrender again (Topic 9.1's composition trick). - 4
Custom hooks to read them. Export small functions such as
useTasks()anduseTasksDispatch()from the same file, and keep the contexts themselves private. A custom hook is your own function whose name starts withuseand that calls other hooks (Phase 10 covers them in depth). These hooks hide which context is used, and they can check for a missing provider: create the context withnullas the default and throw a clear error, such asuseTasks must be used inside <TasksProvider>, instead of crashing later onnull.map. You can add more hooks: action hooks that return named functions (const { addTask } = useTaskActions()) and derived hooks (useRemainingCount()). - 5
One file per store. A typical layout is
TasksContext.jsx(ortasks-store.jsx) containing the reducer, the initial state, the two private contexts, the provider and the hooks. Components import only the provider and the hooks. That gives you a clear boundary: to know every way tasks can change, you read oneswitch. Separate features (cart, notifications, settings) get separate stores, each with its own provider, placed as low in the tree as possible so updates touch as little as they can. - 6
Know its limits. Every component that reads the state context re-renders on every action, even if it only shows one field. For a to-do list, settings or a small shop, that's fine. When the state is large, changes many times a second, or is read by hundreds of components, split it into several stores, or use a library with selectors (Zustand, Redux Toolkit) that renders only the components whose slice changed (Topics 15.2–15.4). And remember server data (lists from an API) belongs in a data library such as TanStack Query (Topic 14.6), not in this store.
Explain it without notes
Why do you combine useReducer with context? What does each part contribute?
Why use two contexts, one for state and one for dispatch?
What is the point of custom hooks like useTasks() instead of calling useContext directly?
What are the limits of reducer + context, and what would you use beyond them?
Practice
Build a NotificationsProvider with a reducer (added, dismissed), two contexts and two hooks, useNotifications() and useNotify() (which returns a notify(text) function).
Write a useTaskActions() hook for the task store that returns { addTask, toggleTask, deleteTask }, and explain why it uses useMemo.
Trade-offs
- ↔
Reducer + context needs no library and is easy to understand, but every state reader re-renders on every action; a store library adds a dependency and gives selectors, devtools and middleware.
- ↔
One store per feature keeps updates small and code clear, but cross-feature actions (signing out clears the cart and the settings) need coordination; one global store makes those easy but re-renders more.
Done when you can
I can build a provider component that owns
useReducerand provides state and dispatch separately.I can explain why dispatch-only components don't re-render.
I can write guard hooks that throw a clear error outside the provider.
I can add action hooks and derived hooks to a store.
I keep contexts private and expose only the provider and hooks.
I know when to move from reducer + context to a store library.