Part 6.4 — Combining Context + useReducer for App-Level State
When state needs to be shared widely and transitions matter, neither Context nor useReducer alone is enough.
Context solves distribution
Reducers solve state transitions
Combined correctly, they give you a clean, Redux-like architecture using only React primitives—no extra libraries, no ceremony.
This post shows how to do it without creating a performance or maintenance trap.
1. When This Architecture Is the Right Choice
Use Context + useReducer when:
state is used across many routes
updates are driven by events (login, logout, refresh)
transitions must be predictable
Redux feels heavy—but
useStateis too weak
Common examples:
authentication session
feature flags
onboarding flows
app-wide notifications
If state is local or feature-scoped, stop here. Don’t globalize prematurely.
2. Define the State and Actions First
Start with intent, not code.
Example: authentication state.
type AuthState = {
status: 'anonymous' | 'authenticated';
user: User | null;
};
type AuthAction =
| { type: 'LOGIN_SUCCESS'; user: User }
| { type: 'LOGOUT' };This already answers:
what can happen
what data exists
what combinations are valid
3. Write a Pure Reducer
function authReducer(
state: AuthState,
action: AuthAction,
): AuthState {
switch (action.type) {
case 'LOGIN_SUCCESS':
return {
status: 'authenticated',
user: action.user,
};
case 'LOGOUT':
return {
status: 'anonymous',
user: null,
};
default:
return state;
}
}No side effects. No async logic. Just transitions.
4. Create Focused Contexts (State vs Actions)
Avoid a single “mega context”.
Create two:
const AuthStateContext =
React.createContext<AuthState | undefined>(undefined);
const AuthDispatchContext =
React.createContext<React.Dispatch<AuthAction> | undefined>(
undefined,
);Why split them?
state changes frequently
dispatch is stable
This dramatically reduces unnecessary re-renders.
5. Build the Provider
function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = React.useReducer(authReducer, {
status: 'anonymous',
user: null,
});
return (
<AuthStateContext.Provider value={state}>
<AuthDispatchContext.Provider value={dispatch}>
{children}
</AuthDispatchContext.Provider>
</AuthStateContext.Provider>
);
}The provider owns:
initialization
reducer wiring
distribution
Consumers stay simple.
6. Create Safe Consumer Hooks
function useAuthState() {
const ctx = React.useContext(AuthStateContext);
if (!ctx) {
throw new Error('useAuthState must be used within AuthProvider');
}
return ctx;
}
function useAuthDispatch() {
const ctx = React.useContext(AuthDispatchContext);
if (!ctx) {
throw new Error('useAuthDispatch must be used within AuthProvider');
}
return ctx;
}This gives you:
clear API
explicit dependencies
safer refactors
7. Using the Architecture in Components
Read state:
const { status, user } = useAuthState();
if (status === 'anonymous') {
return <LoginForm />;
}Trigger transitions:
const dispatch = useAuthDispatch();
dispatch({ type: 'LOGOUT' });Notice:
components don’t know how state changes
they only describe what happened
This decoupling scales extremely well.
8. Where Async Logic Lives
Async logic does not go in reducers.
Create action helpers:
function useAuthActions() {
const dispatch = useAuthDispatch();
async function login(email: string, password: string) {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await res.json();
dispatch({ type: 'LOGIN_SUCCESS', user: data.user });
}
function logout() {
dispatch({ type: 'LOGOUT' });
}
return { login, logout };
}Now:
reducers stay pure
async logic is centralized
UI stays declarative
9. Provider Placement in the Tree
Place providers:
as high as necessary
as low as possible
Example:
<AuthProvider>
<Router />
</AuthProvider>Avoid wrapping providers around parts of the tree that don’t need them.
10. Testing This Architecture
Reducers are easy to test (pure functions).
Providers are tested through behavior:
render(
<AuthProvider>
<TestComponent />
</AuthProvider>,
);Assert:
visible UI
transitions after actions
Never test context internals directly.
11. When This Architecture Breaks Down
Context + reducer struggles when:
state becomes very large
many unrelated domains share the same provider
async flows grow complex
performance tuning becomes manual
That’s the point where Redux Toolkit earns its place.
12. This Is Redux Without the Ceremony
If this feels familiar, it should.
You’ve recreated:
actions
reducers
dispatch
provider
Using React’s built-in tools.
The difference is intentional scope.
13. Summary
You now know how to:
combine Context and useReducer cleanly
split state and dispatch for performance
model app-level state transitions
keep reducers pure
isolate async logic
build a scalable architecture without Redux
Related
Part 6.6 — Async State, Side Effects & Server State Boundaries
Most React state bugs come from mixing UI state with server state. This post teaches you how to draw a hard line between them—and why that line matters.
Part 6.5 — Introducing Redux Toolkit the Right Way
Redux isn’t the default anymore—but when your app needs it, Redux Toolkit is the cleanest, safest way to introduce global state at scale.
Part 6.3 — useReducer for Complex State Transitions
When state stops being simple values and starts behaving like a system, useReducer gives you structure, predictability, and clarity.
Comments