Module: React and Ecosystem
React and Ecosystem·100·8 MIN READ

100: Reducers and Context Architecture

TOPICS COVERED: Reducers and Context Architecture

Learning objectives

You will learn to:

  • model related state transitions with useReducer;
  • write pure reducers;
  • separate state from dispatch logic;
  • use Context without turning it into a universal global store;
  • combine reducer + context for feature-scoped state;
  • split contexts to reduce unnecessary subscriptions;
  • test reducers independently;
  • know when reducer/context should give way to a dedicated state library.

Why reducers exist

useState is excellent for independent values:

jsx
const [open, setOpen] = useState(false);

But a feature can become harder to understand when many handlers update the same object in different ways.

For a task board:

jsx
setTasks(...)
setSelectedId(...)
setFilter(...)
setEditingId(...)

the state transitions may be clearer as named events:

text
task/added
task/toggled
task/deleted
selection/changed

A reducer centralizes those transitions.

Reducer mental model

text
previous state + action -> next state

A reducer must be pure.

jsx
function taskReducer(state, action) {
  switch (action.type) {
    case 'task/added':
      return {
        ...state,
        tasks: [...state.tasks, action.task],
      };

    case 'task/toggled':
      return {
        ...state,
        tasks: state.tasks.map((task) =>
          task.id === action.id
            ? { ...task, completed: !task.completed }
            : task,
        ),
      };

    case 'task/deleted':
      return {
        ...state,
        tasks: state.tasks.filter((task) => task.id !== action.id),
      };

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

Use it:

jsx
const [state, dispatch] = useReducer(taskReducer, {
  tasks: [],
  selectedId: null,
});

Then:

jsx
dispatch({
  type: 'task/added',
  task: {
    id: crypto.randomUUID(),
    title: 'Review reducer',
    completed: false,
  },
});

Why reducers improve reasoning

A reducer gives you:

  • one place to inspect valid state transitions;
  • easy unit testing;
  • named events instead of arbitrary setters;
  • a natural boundary for feature logic.

A reducer does not automatically make state global, persistent, cached, or asynchronous.

Reducers must stay pure

Wrong:

jsx
function reducer(state, action) {
  fetch('/api/tasks', {
    method: 'POST',
    body: JSON.stringify(action.task),
  });

  return state;
}

Wrong:

jsx
state.tasks.push(action.task);
return state;

Correct reducer responsibilities:

  • calculate the next state;
  • return new objects when data changes;
  • avoid network calls, timers, storage writes, random external mutation, or DOM work.

Generate IDs before dispatch if the ID generation is part of an event workflow.

Context solves value delivery

Context lets a distant descendant read a value without threading the same prop through every intermediate component.

jsx
const ThemeContext = createContext('light');

function App() {
  return (
    <ThemeContext value="dark">
      <Dashboard />
    </ThemeContext>
  );
}

Read it:

jsx
function Toolbar() {
  const theme = useContext(ThemeContext);
  return <div data-theme={theme}>...</div>;
}

In React 19, the context object itself can be used as a provider:

jsx
<ThemeContext value={theme}>
  <App />
</ThemeContext>

Older code commonly uses:

jsx
<ThemeContext.Provider value={theme}>
  <App />
</ThemeContext.Provider>

You should be able to recognize both.

Context is not automatically state management

Context transports a value. It does not decide:

  • how the value changes;
  • whether updates are cached;
  • whether data is server-owned;
  • whether writes are optimistic;
  • how to persist state.

This is why reducer + context is a common pairing.

Reducer + Context feature architecture

jsx
import {
  createContext,
  useContext,
  useMemo,
  useReducer,
} from 'react';

const TaskStateContext = createContext(null);
const TaskDispatchContext = createContext(null);

function taskReducer(state, action) {
  switch (action.type) {
    case 'added':
      return [...state, action.task];

    case 'toggled':
      return state.map((task) =>
        task.id === action.id
          ? { ...task, completed: !task.completed }
          : task,
      );

    default:
      return state;
  }
}

export function TaskProvider({ children }) {
  const [tasks, dispatch] = useReducer(taskReducer, []);

  return (
    <TaskStateContext value={tasks}>
      <TaskDispatchContext value={dispatch}>
        {children}
      </TaskDispatchContext>
    </TaskStateContext>
  );
}

export function useTasks() {
  const value = useContext(TaskStateContext);

  if (value === null) {
    throw new Error('useTasks must be used inside TaskProvider');
  }

  return value;
}

export function useTaskDispatch() {
  const value = useContext(TaskDispatchContext);

  if (value === null) {
    throw new Error(
      'useTaskDispatch must be used inside TaskProvider',
    );
  }

  return value;
}

Splitting state and dispatch is useful because dispatch has a stable identity. A component that only needs to dispatch does not need to receive the state value.

Context update behavior

When a provider's value changes, consumers reading that context rerender.

This can surprise developers:

jsx
<SettingsContext value={{ theme, locale }}>

A new object is created on every render.

This does not mean you should automatically wrap every provider value in useMemo. First design smaller contexts around actual subscription needs.

For example:

jsx
<ThemeContext value={theme}>
  <LocaleContext value={locale}>
    {children}
  </LocaleContext>
</ThemeContext>

can be easier to reason about than one giant settings context.

Context boundaries

Good context candidates:

  • current theme;
  • current authenticated account;
  • feature reducer state;
  • locale;
  • dependency injection for a service;
  • deeply shared configuration.

Poor candidates:

  • every server response;
  • a text input draft used by one form;
  • a value that could be passed through one parent;
  • fast-changing large objects consumed by the whole app.

Selector problem

Native Context does not provide fine-grained selectors like a dedicated store.

If a large context changes frequently, every consumer reading it may rerender even if a consumer only cares about one field.

Solutions include:

  • split the context;
  • move state closer to consumers;
  • use an external store architecture;
  • use Redux Toolkit, Zustand, or another library when the subscription model justifies it.

Do not prematurely install a store to avoid learning ownership.

Reducer example with invariant checks

jsx
function cartReducer(state, action) {
  switch (action.type) {
    case 'quantity/changed': {
      const quantity = Math.max(0, action.quantity);

      return {
        ...state,
        lines: state.lines.map((line) =>
          line.id === action.id
            ? { ...line, quantity }
            : line,
        ),
      };
    }

    default:
      return state;
  }
}

The reducer is an excellent place to enforce state invariants because every transition passes through it.

Server/business validation still belongs on the server.

Testing a reducer

jsx
import { describe, expect, test } from 'vitest';

describe('taskReducer', () => {
  test('toggles one task without mutating the original', () => {
    const state = [
      { id: 'a', title: 'One', completed: false },
      { id: 'b', title: 'Two', completed: false },
    ];

    const next = taskReducer(state, {
      type: 'task/toggled',
      id: 'b',
    });

    expect(next[1].completed).toBe(true);
    expect(state[1].completed).toBe(false);
    expect(next[0]).toBe(state[0]);
  });
});

Pure transitions are cheap to test.

Reducer versus Redux Toolkit

useReducer is scoped to the component tree containing it.

Redux Toolkit adds:

  • a standalone external store;
  • selector-based subscriptions;
  • DevTools history;
  • middleware;
  • feature slices;
  • strong patterns for cross-feature client state.

Later, this course uses Redux Toolkit only when the state actually benefits from those capabilities.

Server state remains TanStack Query v5 in this curriculum.

Common mistakes

Dispatching setters instead of domain events

Less expressive:

jsx
dispatch({
  type: 'setTasks',
  tasks: newTasks,
});

More expressive:

jsx
dispatch({
  type: 'task/toggled',
  id,
});

The second describes what happened.

Giant context

One AppContext containing dozens of unrelated fields becomes a global rerender and coupling boundary.

Network logic in reducer

Reducers calculate state. They do not perform Effects.

Ignoring invalid actions

During development, throwing for an unknown action can expose wiring mistakes earlier than silently returning state.

Exercises

  1. Convert three related useState values into one reducer.
  2. Add task/renamed, task/deleted, and tasks/reset actions.
  3. Split one giant context into state and dispatch contexts.
  4. Write reducer tests for immutability.
  5. Explain whether server tasks should be moved into reducer/context once TanStack Query is installed.

Exit questions

  1. What makes a reducer pure?
  2. What problem does Context solve?
  3. Why is Context not the same as a store?
  4. Why might splitting contexts improve architecture?
  5. When is useReducer more readable than multiple setters?
  6. Why should server state usually not be duplicated into reducer/context?

Official references


Deep dive: reducer design should model domain transitions

Reducers become powerful when action names describe events, not setter operations.

Weak:

jsx
dispatch({
  type: 'setTasks',
  payload: nextTasks,
});

Stronger:

jsx
dispatch({
  type: 'task/completed',
  taskId,
});

The reducer now owns the transition logic.

This improves:

  • testability;
  • logging;
  • invariants;
  • future behavior changes.

Event vocabulary

For a task editor:

text
draft/titleChanged
draft/priorityChanged
draft/reset
save/started
save/succeeded
save/failed

Do not add every possible action preemptively. Build vocabulary around real workflows.

Reducer invariant

Suppose quantity cannot be negative.

Reducer:

jsx
function cartReducer(state, action) {
  switch (action.type) {
    case 'quantity/changed': {
      const quantity = Math.max(0, action.quantity);

      return {
        ...state,
        lines: state.lines.map((line) =>
          line.id === action.lineId
            ? { ...line, quantity }
            : line,
        ),
      };
    }

    default:
      return state;
  }
}

The state cannot accidentally transition to a negative quantity through this action.

Server validation still repeats business rules.

Reducer initialization

Use an initializer for expensive derived initial state:

jsx
function init(initialTasks) {
  return {
    tasks: initialTasks,
    selectedId: null,
    filter: 'all',
  };
}

const [state, dispatch] = useReducer(
  reducer,
  initialTasks,
  init,
);

Initializer must remain pure.

Reducer and async work

Reducer:

text
calculates state

Event/Action/thunk/query mutation:

text
performs async work

Example:

jsx
async function handleCreate(input) {
  dispatch({ type: 'create/started' });

  try {
    const task = await api.createTask(input);

    dispatch({
      type: 'create/succeeded',
      task,
    });
  } catch (error) {
    dispatch({
      type: 'create/failed',
      error,
    });
  }
}

This is a learning architecture.

Later TanStack Query should own server-write lifecycle rather than duplicating it in a client reducer.

Context provider placement

Provider scope matters.

Global:

jsx
<TaskProvider>
  <App />
</TaskProvider>

means every route can access task state.

Feature-scoped:

jsx
<Route>
  <TaskProvider>
    <TaskWorkspace />
  </TaskProvider>
</Route>

limits lifetime and consumers.

Choose the smallest meaningful scope.

Context default values

Avoid a fake usable default when provider is required:

jsx
const TaskContext = createContext({
  tasks: [],
  dispatch() {},
});

A component accidentally outside the provider silently uses fake data.

Prefer:

jsx
const TaskContext = createContext(null);

function useTaskContext() {
  const context = useContext(TaskContext);

  if (context === null) {
    throw new Error('useTaskContext must be used within TaskProvider');
  }

  return context;
}

Fail fast.

Context and rerender granularity

Provider:

jsx
<TaskContext value={{ tasks, dispatch, selectedId }}>

Consumers reading that context subscribe to changes in its value.

Splitting:

jsx
<TaskStateContext value={state}>
  <TaskDispatchContext value={dispatch}>

means dispatch-only consumers do not need state value.

Further splitting can be appropriate when one value changes much more often than another.

Do not fragment context into dozens of micro-contexts without measurable/architectural value.

Context versus composition

Before Context:

jsx
<Page
  toolbar={<Toolbar user={user} />}
/>

may eliminate several layers of prop threading through layout components.

Context is one tool; composition is another.

Reducer selector pattern

For a reducer object:

jsx
function selectVisibleTasks(state) {
  return state.tasks.filter((task) => {
    ...
  });
}

Pure selectors keep rendering components simpler and are easy to test.

If the calculation becomes expensive, measure before memoizing.

Testing transitions

Table-driven tests:

jsx
test.each([
  ['open', false],
  ['done', true],
])('filters %s tasks', (filter, completed) => {
  ...
});

Reducer tests should verify:

  • correct next state;
  • input state not mutated;
  • unaffected references preserved where appropriate;
  • invalid actions/invariants.

Context does not solve server cache

This is important enough to repeat.

Putting API results in Context gives you:

  • delivery to descendants.

It does not automatically give:

  • stale time;
  • background refetch;
  • invalidation;
  • retries;
  • mutation cache;
  • request deduplication;
  • pagination.

Those are TanStack Query responsibilities later.

When reducer + context becomes strained

Signs:

  • many unrelated contexts;
  • high-frequency updates across large tree;
  • complex cross-feature subscriptions;
  • middleware needs;
  • DevTools/action debugging needed;
  • state must exist outside one React subtree.

That is when an external client-state store can become reasonable.

Failure clinic

Reducer returns undefined

Every action path must return state or intentionally throw.

Mutating nested state

Plain useReducer does not use Immer automatically.

Wrong:

jsx
state.tasks.push(action.task);
return state;

One context for everything

Auth + tasks + notifications + theme + form drafts in one context creates broad coupling.

Server-state reducer duplication

Do not fetch tasks into Query and then dispatch them into reducer "for global access."

Use Query where the resource belongs.

Exercises

  1. Create a reducer event vocabulary for a checkout flow.
  2. Enforce one invariant in reducer logic.
  3. Split state and dispatch contexts.
  4. Write a fail-fast custom context Hook.
  5. Scope a provider to a route instead of the whole app.
  6. Write reducer immutability tests.
  7. Explain when Redux Toolkit would improve over reducer + context.

Mastery check

Explain:

  • action/event modeling;
  • reducer purity;
  • provider scope;
  • context subscription behavior;
  • why Context is not a query cache;
  • when external stores become justified.

Production case study: reducer-driven wizard with invariants

Consider a checkout wizard with steps:

text
customer
delivery
payment
review

Instead of four unrelated booleans:

jsx
const [customerDone, setCustomerDone] = useState(false);
const [deliveryDone, setDeliveryDone] = useState(false);
const [paymentDone, setPaymentDone] = useState(false);
const [step, setStep] = useState('customer');

a reducer can enforce valid transitions:

jsx
const initialState = {
  step: 'customer',
  customer: null,
  delivery: null,
  payment: null,
};

function checkoutReducer(state, action) {
  switch (action.type) {
    case 'customer/completed':
      return {
        ...state,
        customer: action.customer,
        step: 'delivery',
      };

    case 'delivery/completed':
      if (!state.customer) {
        throw new Error('Customer must be completed first.');
      }

      return {
        ...state,
        delivery: action.delivery,
        step: 'payment',
      };

    case 'payment/completed':
      if (!state.customer || !state.delivery) {
        throw new Error('Checkout prerequisites are missing.');
      }

      return {
        ...state,
        payment: action.payment,
        step: 'review',
      };

    case 'step/back':
      return moveBack(state);

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

The reducer becomes executable documentation of legal client transitions.

What the reducer still cannot guarantee

A malicious client can skip steps and call the server.

Server must verify:

text
customer exists
delivery option is valid
price is current
inventory exists
payment belongs to session

Client reducer invariants improve UX and code correctness; they are not security.

Context scope

Wrap only the checkout route:

jsx
<CheckoutProvider>
  <CheckoutRoutes />
</CheckoutProvider>

Leaving checkout removes client wizard state naturally.

If the product requires returning later, persist a server draft or explicit client persistence rather than making the provider global forever.


Additional depth: reducer/context migration and debugging strategy

A common real-world path is:

text
many useState setters
→ reducer
→ reducer + context
→ external store only if subscription/orchestration needs grow

Do not jump directly to the final step before understanding why.

Migration example

Start:

jsx
const [tasks, setTasks] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [sort, setSort] = useState('title');

List every transition in handlers:

text
add task
toggle task
select task
clear selection
change sort

Convert transitions one by one into reducer actions.

After reducer is correct, pass:

jsx
state
dispatch

through props first.

Only introduce Context when prop delivery itself becomes a real problem.

This staged migration isolates bugs:

text
Did transition logic break?
Did provider scope break?
Did consumer subscription break?

rather than changing all three at once.

Debug reducer state with action logs

Temporary development helper:

jsx
function debugReducer(reducer) {
  return (state, action) => {
    const next = reducer(state, action);

    console.groupCollapsed(action.type);
    console.log('previous', state);
    console.log('action', action);
    console.log('next', next);
    console.groupEnd();

    return next;
  };
}

Do not ship sensitive state logging to production.

This exercise previews why Redux DevTools becomes valuable for large external stores.

Context value versioning

If a shared provider becomes a public internal library, changing:

jsx
value={{ tasks, dispatch }}

to:

jsx
value={{ tasks, dispatch, preferences, api, user }}

can silently increase coupling.

Treat Context value shape like an API.

Prefer separate providers when concepts have different ownership/lifetimes.

The important question is not "can Context carry this?" It can. The question is "should these consumers become coupled to the same update boundary?"