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 7.5 — Build Optimization with Vite: Code Splitting & Env Handling
Vite makes development fast by default. This post shows how to make production builds just as intentional—lean bundles, predictable envs, and code that ships only when needed.
Part 7.4 — Performance Hooks: useMemo, useCallback & useDeferredValue
Performance hooks are scalpels, not band-aids. This post teaches you how to use them intentionally—only where they solve real problems.
Part 7.3 — Custom Hooks as Architecture: Patterns & Pitfalls
Custom hooks aren’t just helpers. Used well, they define architectural seams in your React app. Used poorly, they hide complexity and make refactoring painful.
Comments