100: 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.
This lesson is about ownership and boundaries. By the end, you should be able to look at a group of related React state updates and decide whether a reducer makes the transitions clearer, whether Context is appropriate for delivering that state, and where the design should stop before it becomes a substitute for a server cache or an external store.
Why reducers exist
useState is an excellent fit for independent values:
const [open, setOpen] = useState(false);
The difficulty starts when a feature has many handlers updating the same object or several related values in different ways. The individual setters are not wrong, but the rules connecting them become scattered across event handlers.
For a task board, you might have updates such as:
setTasks(...)
setSelectedId(...)
setFilter(...)
setEditingId(...)
Those transitions may be easier to understand when they are represented as named events:
task/added
task/toggled
task/deleted
selection/changed
A reducer centralizes the rules for those transitions. The component still decides when an event occurs, but one function defines what that event does to state.
Reducer mental model
The useful model is deliberately small:
previous state + action -> next state
A reducer must be pure. Given the same state and action, it should calculate the same next state without performing work outside that calculation.
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}`);
}
}
The reducer receives the complete current state and an action that describes what happened. Notice that the update creates a new array and, when needed, a new task object. The task that was not changed keeps its existing reference.
Use it like this:
const [state, dispatch] = useReducer(taskReducer, {
tasks: [],
selectedId: null,
});
Then dispatch a domain event:
dispatch({
type: 'task/added',
task: {
id: crypto.randomUUID(),
title: 'Review reducer',
completed: false,
},
});
The ID is generated before the action reaches the reducer. That keeps randomness out of the reducer itself and makes the reducer's behavior predictable in tests.
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.
That boundary is useful during review and debugging: you can inspect the action, the previous state, and the resulting state without tracing every component handler that might update the feature.
A reducer does not automatically make state global, persistent, cached, or asynchronous. Those are separate architectural concerns.
Reducers must stay pure
The reducer is not an API client or an event-effect coordinator. This is wrong:
function reducer(state, action) {
fetch('/api/tasks', {
method: 'POST',
body: JSON.stringify(action.task),
});
return state;
}
It is also wrong to mutate the existing state:
state.tasks.push(action.task);
return state;
Correct reducer responsibilities are to:
- 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. The handler, action creator, or mutation layer can do that work; the reducer should only interpret the resulting action.
Context solves value delivery
Context addresses a different problem. It lets a distant descendant read a value without threading that value through every intermediate component as a prop.
const ThemeContext = createContext('light');
function App() {
return (
<ThemeContext value="dark">
<Dashboard />
</ThemeContext>
);
}
A descendant can read the nearest provider's value:
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:
<ThemeContext value={theme}>
<App />
</ThemeContext>
Older code commonly uses the explicit provider component:
<ThemeContext.Provider value={theme}>
<App />
</ThemeContext.Provider>
You should recognize both forms when reading existing code. They solve the same value-delivery problem.
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 distinction is where people commonly get confused. A context provider can expose state managed by useState, useReducer, a query library, or something else, but Context itself supplies none of those policies. That is why reducer + context is a common pairing: the reducer owns transitions, while Context owns delivery to the feature's descendants.
Reducer + Context feature architecture
Here is a feature-scoped arrangement that exposes state and dispatch through separate contexts:
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 send an event does not need to subscribe to the state value. The imported useMemo is not needed by this particular example; do not add memoization merely because a context is present.
Context update behavior
When a provider's value changes, consumers reading that context rerender. This can surprise developers when the provider value is an object literal:
<SettingsContext value={{ theme, locale }}>
A new object is created on every render. Consumers compare the context value as a value reference, so the provider can appear changed even when the individual fields have not changed.
This does not mean you should automatically wrap every provider value in useMemo. First design smaller contexts around actual subscription needs. For example:
<ThemeContext value={theme}>
<LocaleContext value={locale}>
{children}
</LocaleContext>
</ThemeContext>
can be easier to reason about than one giant settings context. Memoization can be useful in a measured case, but it does not repair an overly broad ownership boundary.
Context boundaries
Good context candidates include:
- current theme;
- current authenticated account;
- feature reducer state;
- locale;
- dependency injection for a service;
- deeply shared configuration.
Poor candidates include:
- 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.
The question is not whether Context can carry a value. It can. Ask instead whether these consumers should share the same update boundary and lifetime.
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 when a particular consumer cares about only one field. This is a subscription-granularity problem, not proof that Context is broken.
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. Start by identifying who owns the state, who needs to read it, and how frequently each part changes.
Reducer example with invariant checks
Reducers are also a useful place to enforce client-side state invariants. For example, if the UI must never represent a negative quantity:
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;
}
}
Every transition through this reducer applies the same normalization. That makes the reducer an excellent place to enforce state invariants.
Server/business validation still belongs on the server. A client invariant protects the UI from representing an invalid local state; it cannot protect an API from a modified client.
Testing a reducer
Because a pure reducer is an ordinary function, it can be tested without rendering a component:
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]);
});
});
This test checks the changed value, confirms that the input was not mutated, and confirms that an unaffected task kept its reference. Pure transitions are cheap to test, and failures point directly at transition logic rather than at rendering or provider setup.
Reducer versus Redux Toolkit
useReducer is scoped to the component tree containing it. When that tree is removed, the reducer state goes with it unless some other mechanism persists it.
Redux Toolkit adds:
- a standalone external store;
- selector-based subscriptions;
- DevTools history;
- middleware;
- feature slices;
- strong patterns for cross-feature client state.
Those capabilities solve different scaling problems. Later, this course uses Redux Toolkit only when the state actually benefits from those capabilities, rather than treating it as the default replacement for local state.
Server state remains TanStack Query v5 in this curriculum. A client-state reducer and a server-state cache have different ownership and freshness rules.
Common mistakes
Dispatching setters instead of domain events
This action exposes an implementation detail:
dispatch({
type: 'setTasks',
tasks: newTasks,
});
This one describes what happened:
dispatch({
type: 'task/toggled',
id,
});
The second action gives the reducer ownership of the transition logic. That makes it easier to validate the event, log it, and change the implementation later.
Giant context
One AppContext containing dozens of unrelated fields becomes a global rerender and coupling boundary. Consumers that need only theme can become coupled to updates for tasks, notifications, or form drafts.
Network logic in reducer
Reducers calculate state. They do not perform Effects. Put asynchronous work in an event handler, an Effect where appropriate, or the data/mutation layer that owns the request.
Ignoring invalid actions
During development, throwing for an unknown action can expose wiring mistakes earlier than silently returning state. A silent default can conceal a misspelled action type or a component dispatching an event that the reducer never implemented.
Exercises
- Convert three related
useStatevalues into one reducer. - Add
task/renamed,task/deleted, andtasks/resetactions. - Split one giant context into state and dispatch contexts.
- Write reducer tests for immutability.
- Explain whether server tasks should be moved into reducer/context once TanStack Query is installed.
Exit questions
- What makes a reducer pure?
- What problem does Context solve?
- Why is Context not the same as a store?
- Why might splitting contexts improve architecture?
- When is
useReducermore readable than multiple setters? - Why should server state usually not be duplicated into reducer/context?
Official references
- https://react.dev/reference/react/useReducer
- https://react.dev/reference/react/useContext
- https://react.dev/learn/scaling-up-with-reducer-and-context
- https://react.dev/learn/passing-data-deeply-with-context
Deep dive: reducer design should model domain transitions
Once a feature has more than a few transitions, action names become part of its design vocabulary. Reducers become powerful when those names describe events, not setter operations.
Weak:
dispatch({
type: 'setTasks',
payload: nextTasks,
});
Stronger:
dispatch({
type: 'task/completed',
taskId,
});
The reducer now owns the transition logic. The component reports the event and its relevant identity; it does not need to calculate the entire replacement collection before dispatching.
This improves:
- testability;
- logging;
- invariants;
- future behavior changes.
Event vocabulary
For a task editor, a realistic vocabulary might be:
draft/titleChanged
draft/priorityChanged
draft/reset
save/started
save/succeeded
save/failed
These names describe a workflow, including its asynchronous lifecycle. Do not add every possible action preemptively. Build vocabulary around real workflows so the reducer remains legible instead of becoming an inventory of hypothetical events.
Reducer invariant
Suppose quantity cannot be negative. The reducer can make that rule explicit:
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. That is a client-side guarantee for this transition, not a replacement for server validation.
Server validation still repeats business rules. Requests can be forged, clients can be outdated, and multiple clients can act on the same resource.
Reducer initialization
Use an initializer when constructing the initial state requires expensive derived work:
function init(initialTasks) {
return {
tasks: initialTasks,
selectedId: null,
filter: 'all',
};
}
const [state, dispatch] = useReducer(
reducer,
initialTasks,
init,
);
React calls init to derive the initial reducer state from initialTasks. The initializer must remain pure as well: it should calculate a value, not make a request, write to storage, or mutate its input.
Reducer and async work
Keep the division of responsibility visible:
Reducer:
calculates state
Event/Action/thunk/query mutation:
performs async work
For example:
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 for making the lifecycle explicit. Later, TanStack Query should own the server-write lifecycle rather than duplicating request status and cache behavior in a client reducer.
Context provider placement
Provider scope matters. A provider placed at the application root has a different lifetime and subscription surface from one placed around a route.
Global:
<TaskProvider>
<App />
</TaskProvider>
means every route can access task state. That may be correct for genuinely application-wide client state, but it also keeps the state alive and exposes it to more consumers.
Feature-scoped:
<Route>
<TaskProvider>
<TaskWorkspace />
</TaskProvider>
</Route>
limits the provider's lifetime and consumers. Choose the smallest meaningful scope. A provider should be as broad as its ownership requires, not broader merely for convenience.
Context default values
Avoid a fake usable default when a provider is required:
const TaskContext = createContext({
tasks: [],
dispatch() {},
});
A component accidentally rendered outside the provider now silently uses fake data and a no-op dispatch. That can hide a composition bug until much later.
Prefer a null default and a fail-fast custom Hook:
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. The error identifies the missing provider at the point where the invalid read occurs.
Context and rerender granularity
With one combined value:
<TaskContext value={{ tasks, dispatch, selectedId }}>
Consumers reading that context subscribe to changes in its value.
Splitting it:
<TaskStateContext value={state}>
<TaskDispatchContext value={dispatch}>
means dispatch-only consumers do not need the 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 or architectural value. Each provider adds a boundary developers must understand; split when ownership or subscription behavior makes the boundary useful.
Context versus composition
Before adding Context, consider whether composition can remove the prop-threading problem:
<Page
toolbar={<Toolbar user={user} />}
/>
Passing a ready-to-render slot may eliminate several layout components that would otherwise forward user without using it. Context is one tool; composition is another. The simplest solution is often the one that keeps ownership visible.
Reducer selector pattern
For a reducer object, a pure selector can keep derived-data logic out of rendering components:
function selectVisibleTasks(state) {
return state.tasks.filter((task) => {
...
});
}
Pure selectors keep rendering components simpler and are easy to test. They do not automatically create fine-grained Context subscriptions; they only centralize the calculation. If the calculation becomes expensive, measure before memoizing.
Testing transitions
Table-driven tests make it straightforward to check several related cases:
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.
The goal is not only to prove that one happy-path value changed. Tests should also protect the reducer's immutability contract and its response to malformed or unsupported transitions.
Context does not solve server cache
This distinction 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. If the data is owned by the server, choose a tool that understands freshness, synchronization, and request lifecycles instead of copying the response into a general-purpose Context.
When reducer + context becomes strained
Watch for these 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.
At that point, an external client-state store can become reasonable. This is not a hard threshold or a performance superstition. It is a signal that the provider boundary, subscription model, or orchestration needs are no longer a good fit for reducer + Context.
Failure clinic
Reducer returns undefined
Every action path must return state or intentionally throw. An accidental undefined result breaks the reducer contract and usually causes the failure to appear far from the action that caused it.
Mutating nested state
Plain useReducer does not use Immer automatically. You must perform immutable updates yourself.
Wrong:
state.tasks.push(action.task);
return state;
One context for everything
Auth + tasks + notifications + theme + form drafts in one context creates broad coupling. It also makes otherwise unrelated consumers share an update boundary.
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. A second copy creates synchronization questions without giving the reducer ownership of the server resource.
Deep-dive exercises
- Create a reducer event vocabulary for a checkout flow.
- Enforce one invariant in reducer logic.
- Split state and dispatch contexts.
- Write a fail-fast custom context Hook.
- Scope a provider to a route instead of the whole app.
- Write reducer immutability tests.
- 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 these steps:
customer
delivery
payment
review
The sequence is meaningful: delivery requires customer information, payment requires both earlier stages, and review should represent a completed checkout draft. Instead of four unrelated booleans and a separate current-step value:
const [customerDone, setCustomerDone] = useState(false);
const [deliveryDone, setDeliveryDone] = useState(false);
const [paymentDone, setPaymentDone] = useState(false);
const [step, setStep] = useState('customer');
a reducer can keep the data and legal transitions together:
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 is executable documentation of legal client transitions. A test can dispatch delivery/completed with no customer and verify the intended failure, while a successful action makes the next step explicit in the returned state.
What the reducer still cannot guarantee
A malicious client can skip steps and call the server. Client checks improve the experience for an honest user, but they are not an authorization or security boundary.
The server must verify:
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. The server must re-check rules against trusted data before accepting the operation.
Context scope
Wrap only the checkout route:
<CheckoutProvider>
<CheckoutRoutes />
</CheckoutProvider>
Leaving checkout removes client wizard state naturally. That lifetime is often exactly what a checkout draft needs during one visit.
If the product requires returning later, persist a server draft or explicit client persistence rather than making the provider global forever. Persistence is a product and ownership decision, not an automatic consequence of using Context.
Additional depth: reducer/context migration and debugging strategy
A common real-world path is gradual:
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. Each move changes a different boundary, and separating those changes makes the resulting bugs easier to locate.
Migration example
Start with local state:
const [tasks, setTasks] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [sort, setSort] = useState('title');
List every transition currently performed in handlers:
add task
toggle task
select task
clear selection
change sort
Convert transitions one by one into reducer actions. Do not begin by moving everything into Context; first make the reducer's state transitions correct and testable.
After the reducer is correct, pass:
state
dispatch
through props first. Only introduce Context when prop delivery itself becomes a real problem. This preserves a clear answer to the question of whether the issue is state-transition logic or component composition.
This staged migration isolates bugs:
Did transition logic break?
Did provider scope break?
Did consumer subscription break?
rather than changing all three at once. The distinction is practical: a failing reducer test points to transition logic, while a missing-provider error points to composition, and unexpected broad rerenders point to subscription design.
Debug reducer state with action logs
A temporary development helper can show the complete sequence of transitions:
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;
};
}
When a UI appears to skip a transition, compare the dispatched action with the previous and next state in this log. That can reveal a wrong action payload, an unexpected action order, or a reducer branch that does not implement the intended event.
Do not ship sensitive state logging to production. Logs can expose account data, tokens, payment details, or other information that does not belong in a browser console or centralized log system.
This exercise previews why Redux DevTools becomes valuable for large external stores: action history and state transitions remain inspectable without permanently writing ad hoc logs throughout components.
Context value versioning
If a shared provider becomes a public internal library, changing its value shape from:
value={{ tasks, dispatch }}
to:
value={{ tasks, dispatch, preferences, api, user }}
can silently increase coupling. Existing consumers may continue to work, but the provider now communicates more concepts through one update boundary and encourages new consumers to depend on unrelated values.
Treat Context value shape like an API. Prefer separate providers when concepts have different ownership or lifetimes. A task provider should not grow into the place where every service and application preference is delivered simply because it is already nearby.
The important question is not "can Context carry this?" It can. The question is "should these consumers become coupled to the same update boundary?"
