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.
Why reducers exist
useState is excellent for independent values:
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:
setTasks(...)
setSelectedId(...)
setFilter(...)
setEditingId(...)
the state transitions may be clearer as named events:
task/added task/toggled task/deleted selection/changed
A reducer centralizes those transitions.
Reducer mental model
previous state + action -> next state
A reducer must be pure.
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:
const [state, dispatch] = useReducer(taskReducer, {
tasks: [],
selectedId: null,
});
Then:
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:
function reducer(state, action) {
fetch('/api/tasks', {
method: 'POST',
body: JSON.stringify(action.task),
});
return state;
}
Wrong:
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.
const ThemeContext = createContext('light');
function App() {
return (
<ThemeContext value="dark">
<Dashboard />
</ThemeContext>
);
}
Read it:
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:
<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
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:
<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:
<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
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
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:
dispatch({
type: 'setTasks',
tasks: newTasks,
});
More expressive:
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
- 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
Reducers become powerful when action 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.
This improves:
- testability;
- logging;
- invariants;
- future behavior changes.
Event vocabulary
For a task editor:
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:
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:
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:
calculates state
Event/Action/thunk/query mutation:
performs async work
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.
Later TanStack Query should own server-write lifecycle rather than duplicating it in a client reducer.
Context provider placement
Provider scope matters.
Global:
<TaskProvider>
<App />
</TaskProvider>
means every route can access task state.
Feature-scoped:
<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:
const TaskContext = createContext({
tasks: [],
dispatch() {},
});
A component accidentally outside the provider silently uses fake data.
Prefer:
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:
<TaskContext value={{ tasks, dispatch, selectedId }}>
Consumers reading that context subscribe to changes in its value.
Splitting:
<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:
<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:
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:
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:
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
- 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 steps:
customer delivery payment review
Instead of four unrelated booleans:
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:
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:
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:
<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:
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:
const [tasks, setTasks] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [sort, setSort] = useState('title');
List every transition in handlers:
add task toggle task select task clear selection change sort
Convert transitions one by one into reducer actions.
After reducer is correct, pass:
state dispatch
through props first.
Only introduce Context when prop delivery itself becomes a real problem.
This staged migration isolates bugs:
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:
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:
value={{ tasks, dispatch }}
to:
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?"
