Reducers and Context in React

A reducer puts related state transitions in one function. Context makes a value available to descendants. Together, they can give a feature one clear state owner without passing the same state and handlers through every intermediate component.

Manage related updates with useReducer

useReducer is a state Hook. Like useState, it stores state between renders. Instead of calling a different setter for each change, an event handler dispatches an action that describes what happened. React passes the current state and that action to the reducer, then renders with the state the reducer returned.

Dispatch an actionAn event handler calls dispatch({ type: "added", ... }).
ReduceThe reducer calculates the next state from the current state and action.
RenderReact stores the returned state and renders the component again.

dispatch schedules the next render. Reading state immediately after dispatch still gives the current render's snapshot, not the state for the next render.

A task list uses one reducer for adding, toggling, and deleting tasks.
import { useReducer, useState } from "react";

function tasksReducer(tasks, action) {
  switch (action.type) {
    case "added":
      return [
        ...tasks,
        { id: action.id, text: action.text, done: false }
      ];

    case "toggled":
      return tasks.map(task =>
        task.id === action.id
          ? { ...task, done: !task.done }
          : task
      );

    case "deleted":
      return tasks.filter(task => task.id !== action.id);

    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

let nextId = 2;
const initialTasks = [
  { id: 1, text: "Learn reducers", done: false }
];

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
  const [text, setText] = useState("");

  function handleAdd() {
    if (!text.trim()) return;
    dispatch({ type: "added", id: nextId++, text });
    setText("");
  }

  return (
    <section>
      <input
        value={text}
        onChange={event => setText(event.target.value)}
      />
      <button onClick={handleAdd}>Add</button>
      <ul>
        {tasks.map(task => (
          <li key={task.id}>
            <label>
              <input
                type="checkbox"
                checked={task.done}
                onChange={() => dispatch({
                  type: "toggled", id: task.id
                })}
              />
              {task.text}
            </label>
            <button onClick={() => dispatch({
              type: "deleted", id: task.id
            })}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}

Reducer design

A reducer is a regular JavaScript function with the shape (state, action) => nextState. It should describe state transitions, while event handlers describe events from the interface.

  • Keep it pure.

    For the same state and action, return the same result. Do not send requests, start timers, read random values, or modify variables outside the reducer.

  • Treat state as read-only.

    Return new objects and arrays instead of mutating the current state. React may call reducers more than once in development Strict Mode to expose accidental impurities.

  • Name actions after events.

    Types such as "task_added" and "form_submitted" explain what happened. Put the data needed for that transition in the action object.

  • Let one action represent one interaction.

    A single action can update several related fields. This keeps the full transition together and prevents partial updates.

  • Fail loudly for unknown actions.

    Throwing in the default branch reveals misspelled or unhandled action types during development.

Choose useState

Good fit
A few independent values with straightforward updates.
Benefit
Less setup and direct setter calls.

Choose useReducer

Good fit
Related fields, many update paths, or transitions worth testing separately.
Benefit
Update logic has one named, predictable home.

A reducer does not make state global. useReducer still owns state in the component that calls it. Lift that component or add context only when descendants genuinely need the same state.

Share values deeply with useContext

Context lets a parent provide a value to any descendant below it, even through components that do not use that value. A component calling useContext(SomeContext) reads the value from the closest matching provider above it.

A theme provider lets a deeply nested button read the current theme without receiving a theme prop at every level.
import { createContext, useContext, useState } from "react";

const ThemeContext = createContext("light");

function Toolbar() {
  // Toolbar does not need to receive or forward a theme prop.
  return <SaveButton />;
}

function SaveButton() {
  const theme = useContext(ThemeContext);
  return <button className={theme}>Save</button>;
}

export default function App() {
  const [theme, setTheme] = useState("dark");

  return (
    <ThemeContext.Provider value={theme}>
      <Toolbar />
      <button onClick={() => setTheme(
        theme === "dark" ? "light" : "dark"
      )}>
        Switch theme
      </button>
    </ThemeContext.Provider>
  );
}
  • The closest provider wins.

    Providers can be nested. A consumer reads the first matching provider found while moving upward through the rendered tree.

  • The default is only a fallback.

    The value passed to createContext is used only when no matching provider exists above the consumer. It does not change when provider state changes.

  • Context is not a separate state store.

    It transports a value. State still belongs to a component using useState or useReducer, and that component passes the value to the provider.

  • Use it for genuinely shared values.

    Theme, authenticated user, routing, and feature-level state are common examples. Ordinary props remain clearer when only a few nearby components need the data.

Combine a reducer with context

A common feature pattern puts useReducer in a provider component. One context supplies the current state and another supplies dispatch. Descendants can then read state or request updates without receiving both values through every intermediate component.

The provider owns task state. Custom Hooks give consumers a small API and produce a useful error outside the provider.
import {
  createContext,
  useContext,
  useReducer
} from "react";

const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);

export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(
    tasksReducer,
    initialTasks
  );

  return (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>
        {children}
      </TasksDispatchContext.Provider>
    </TasksContext.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;
}
Consumers opt into only the capability they need.
function TaskCount() {
  const tasks = useTasks();
  return <p>{tasks.length} tasks</p>;
}

function DeleteButton({ taskId }) {
  const dispatch = useTasksDispatch();

  return (
    <button onClick={() => dispatch({
      type: "deleted",
      id: taskId
    })}>
      Delete
    </button>
  );
}

function App() {
  return (
    <TasksProvider>
      <TaskCount />
      <TaskList />
    </TasksProvider>
  );
}

Splitting state and dispatch is useful because components that only dispatch do not also subscribe to the changing state context. Components that read TasksContext render again when its provided value changes. Reach for further optimization only after measuring a real performance problem.

A practical decision guide

  • Start with local state.

    Use useState when updates are simple. Move to useReducer when related transitions become difficult to understand or test.

  • Keep ownership visible.

    The component calling the state Hook owns the state, even when context makes it available elsewhere.

  • Add context for reach, not complexity.

    Context reduces repeated prop forwarding. A reducer organizes updates. Neither automatically requires the other.

  • Keep the provider close.

    Wrap the smallest meaningful feature subtree so dependencies and update scope remain clear.

Check your understanding

  1. What must a reducer return?
    Show answer

    It must return the complete next state for the action. It should calculate that state without mutating the current state or causing side effects.

  2. Does calling useContext create shared state?
    Show answer

    No. Context reads a provided value. A component still owns changing data with a state Hook and supplies that data through a provider.

  3. Why might an app use separate contexts for reducer state and dispatch?
    Show answer

    It gives consumers a clearer dependency. A component that only dispatches actions does not need to subscribe to the changing state context.