Context and Reducers

Combining Context with a Reducer

The two tools fit together into a small, self-contained store. A reducer owns the data and the transitions; one context publishes the state, a second publishes dispatch. Components that only trigger actions subscribe to the second context and are never re-rendered by a data change.

A task list whose state and dispatch travel by contextHTML
<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">
{ "imports": { "react": "https://esm.sh/react@19.3.0",
  "react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }
</script>
<script type="text/babel" data-type="module">
  import React, { createContext, useContext, useReducer } from 'react';
  import { createRoot } from 'react-dom/client';
  const TasksContext = createContext(null), TasksDispatch = createContext(null);
  function reducer(tasks, action) {
    switch (action.type) {
      case 'added': return [...tasks, { id: Date.now(), text: action.text }];
      case 'cleared': return [];
      default: throw new Error(action.type);
    }
  }
  function TasksProvider({ children }) {
    const [tasks, dispatch] = useReducer(reducer, [{ id: 1, text: 'Split the context' }]);
    return <TasksContext value={tasks}>
      <TasksDispatch value={dispatch}>{children}</TasksDispatch></TasksContext>;
  }
  function TaskList() {                              // reads, never dispatches
    const tasks = useContext(TasksContext);
    return <ul style={{ font: '16px system-ui', margin: '4px 0' }}>
      {tasks.map(t => <li key={t.id}>{t.text}</li>)}</ul>;
  }
  function Toolbar() {                               // dispatches, never reads
    const d = useContext(TasksDispatch);
    return <><button onClick={() => d({ type: 'added', text: 'Ship it' })}>Add</button>{' '}
      <button onClick={() => d({ type: 'cleared' })}>Clear</button></>;
  }
  createRoot(document.getElementById('root')).render(
    <TasksProvider><TaskList /><Toolbar /></TasksProvider>);
  setTimeout(() => document.querySelector('button').click(), 600);   // scripted click
</script>
Browser output of Listing 2.38
Browser output of 38

The second item in the screenshot was added by a scripted click on Add. Toolbar received no prop and never read the task list, yet it changed it; TaskList never learned where the data came from. Each component subscribes to exactly one of the two contexts, which is what keeps the toolbar out of every list re-render.

In a real project TasksProvider, the reducer and two one-line hooks — const useTasks = () => useContext(TasksContext) — live in one TasksContext.js module, and nothing outside it imports the raw contexts. That module is then the whole public interface of the feature: if it later becomes a Redux 3,824 slice (createSlice) or a Zustand 58,763 store, the components calling useTasks() do not change.