Command Palette

Search for a command to run...

Hectal
PHASE 9Intermediate ~28 min· topic 6 of 6

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 useReducer and renders the providers around children.
Custom hook
Your own function whose name starts with use and that calls other hooks, like useTasks().
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.

TasksContext.jsxwhole filejsx
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.

TasksContext.jsxwhole filejsx
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.

TasksContext.jsxwhole filejsx
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.

App.jsxwhole filejsx
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.

See the data flowdiagram
Rendering diagram…

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. 1

    Predict who renders while typing

    In the task store, type a letter in the New task box. Predict the console. (AddTask rendered only: the text is AddTask's own state, and nothing in the store changed.)

  2. 2

    Merge the contexts and compare

    Change the provider to one context holding { tasks, dispatch } (memoised with useMemo), and make AddTask read it. Tick a task. What new line appears? (AddTask rendered: it now reads a value that changes with every action.)

  3. 3

    Add a derived hook

    In the task store, write function useRemainingCount() { return useTasks().filter((t) => !t.done).length; } and use it in Summary. The component gets shorter, and other components can reuse the same calculation.

Code & diagrams

A task store: reducer + two contexts + custom hooks New tab

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

Press Run to see the page.

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
A small shop store: action hooks and derived hooks New tab

`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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Hi, Asha · 3 items · ₹55
The guard hook: a clear error when the provider is missing New tab

This 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

Press Run to see the page.

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.
How it looks as files in a real projectjsx
// 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 /></>.

terminal
$ Console
── what you'll see ──
AddTask rendered
TaskList rendered: 2 tasks
AddTask rendered
TaskList rendered: 2 tasks
Uncaught Error: useTasks must be used inside <TasksProvider>
An error occurred in the <Summary> 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.

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).

terminal
$ Console
── what you'll see ──
Uncaught TypeError: Cannot read properties of null (reading 'length')
An error occurred in the <TaskList> 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.

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 UserContext and a CartContext fed 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 localStorage with 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 is Tasks | null; the guard hook narrows it to Tasks, so every component gets a non-null type without ! (Topic 12.5).

Remember this

  1. 1

    Why combine them. A reducer (Topic 9.4) puts the update logic in one place, but state and dispatch still 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 and dispatch, 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. 2

    Two contexts: state and dispatch. Put tasks in a TasksContext and dispatch in a separate TasksDispatchContext. The state changes on every action; dispatch never 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 no useCallback or useMemo.

  3. 3

    A provider component. Wrap the reducer and both providers in one component, TasksProvider({ children }). It owns useReducer, renders the two providers, and renders children inside. The rest of the app just writes <TasksProvider><App /></TasksProvider>. When the state changes, TasksProvider re-renders, but the children elements were created by its parent, so only the components that read TasksContext render again (Topic 9.1's composition trick).

  4. 4

    Custom hooks to read them. Export small functions such as useTasks() and useTasksDispatch() from the same file, and keep the contexts themselves private. A custom hook is your own function whose name starts with use and 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 with null as the default and throw a clear error, such as useTasks must be used inside <TasksProvider>, instead of crashing later on null.map. You can add more hooks: action hooks that return named functions (const { addTask } = useTaskActions()) and derived hooks (useRemainingCount()).

  5. 5

    One file per store. A typical layout is TasksContext.jsx (or tasks-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 one switch. 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. 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

01

Why do you combine useReducer with context? What does each part contribute?

02

Why use two contexts, one for state and one for dispatch?

03

What is the point of custom hooks like useTasks() instead of calling useContext directly?

04

What are the limits of reducer + context, and what would you use beyond them?

Practice

01

Build a NotificationsProvider with a reducer (added, dismissed), two contexts and two hooks, useNotifications() and useNotify() (which returns a notify(text) function).

02

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 useReducer and 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.