useReducer Patterns: Complex State Without a State Manager
Skip Redux for most apps. useReducer handles complex state logic cleanly — here's how to structure actions, derived state, and async flows in real React code.
You Probably Don't Need Redux
Honestly, the React ecosystem spent years convincing developers that every app with more than three pieces of state needs a dedicated state manager. Redux, MobX, Zustand, Jotai — they're all fine tools. But they're also often complete overkill.
React ships with useReducer. It's been there since 16.8. And for the kind of state complexity that typically drives people toward external libraries — multi-step forms, async loading states, feature flags, UI modes — it handles it well without adding a single dependency.
This article is about using useReducer seriously. Not the toy counter example from every tutorial. Real patterns you can copy into a production codebase right now.
How useReducer Actually Works
useReducer takes two arguments: a reducer function and an initial state. The reducer receives the current state plus an action object, and returns the next state. That's the whole contract. Clean, predictable, testable.
Where it shines over useState is when multiple pieces of state need to change together. Think of a data-fetching flow: you need loading, data, error, and maybe a retryCount to all update in sync based on the same event. With useState you're juggling four separate setters. With useReducer you dispatch one action and the reducer handles all of them atomically.
The mental model shift matters here. You stop thinking about *setting* state and start thinking about *events* — things that happened in your app. That discipline pays dividends as the app grows.
Structuring Your Actions and State Types
Here's the thing: most useReducer pain comes from weak action typing. If your actions are untyped strings, you lose autocomplete, refactoring support, and the ability to catch bugs at compile time. TypeScript fixes this completely.
Use discriminated unions for your action type. Each action variant becomes its own type with a type literal. The reducer's switch statement then narrows correctly — TypeScript knows exactly what payload shape each case gets.
type FormState = {
step: 1 | 2 | 3;
loading: boolean;
error: string | null;
values: { email: string; plan: 'free' | 'pro' | 'lifetime' };
};
type FormAction =
| { type: 'NEXT_STEP' }
| { type: 'PREV_STEP' }
| { type: 'SET_EMAIL'; payload: string }
| { type: 'SET_PLAN'; payload: FormState['values']['plan'] }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_ERROR'; payload: string };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'NEXT_STEP':
return { ...state, step: Math.min(state.step + 1, 3) as FormState['step'] };
case 'PREV_STEP':
return { ...state, step: Math.max(state.step - 1, 1) as FormState['step'] };
case 'SET_EMAIL':
return { ...state, values: { ...state.values, email: action.payload } };
case 'SET_PLAN':
return { ...state, values: { ...state.values, plan: action.payload } };
case 'SUBMIT_START':
return { ...state, loading: true, error: null };
case 'SUBMIT_SUCCESS':
return { ...state, loading: false };
case 'SUBMIT_ERROR':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}If you're building forms with this pattern, it pairs naturally with the field-level validation approach covered in the React Hook Form deep dive. You can run useReducer for your wizard state while delegating individual input validation to a library.
Handling Async Flows Without a Thunk
Redux made dispatch synchronous and pushed async logic into middleware (thunks, sagas, epics). useReducer gives you the same synchronous dispatch. But since you're not in Redux, you don't need middleware at all — your async logic lives in event handlers or useEffect blocks, and you dispatch state transitions before and after the async work.
This pattern is simpler than it sounds. You dispatch SUBMIT_START, fire your API call, then dispatch either SUBMIT_SUCCESS or SUBMIT_ERROR depending on the result. The reducer stays pure. The async coordination stays in the component. Nothing magical required.
const [state, dispatch] = useReducer(formReducer, initialState);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
dispatch({ type: 'SUBMIT_START' });
try {
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state.values),
});
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (err) {
dispatch({
type: 'SUBMIT_ERROR',
payload: err instanceof Error ? err.message : 'Something went wrong',
});
}
}Keep your reducers pure. Never put fetch calls inside a reducer — they need to remain synchronous functions that return the next state. If you ever catch yourself wanting side effects in a reducer, that's a signal the side effect belongs in the component or a custom hook.
Derived State and Avoiding Redundancy
One of the easiest mistakes is storing derived values in state. If you can compute it from existing state, don't store it. Storing isLastStep alongside step and totalSteps means you now have three things to keep in sync. Compute it instead.
Derived state lives outside the reducer, computed inline or via useMemo. The reducer only owns the *minimal* set of facts. Everything else is a view over those facts. This keeps your state shape small and your reducer logic focused.
const isLastStep = state.step === 3;
const canGoBack = state.step > 1;
const progressPercent = Math.round((state.step / 3) * 100);
// Tailwind progress bar with inline width, 8px height
// <div style={{ width: `${progressPercent}%`, height: '8px' }} className="bg-violet-500 rounded-full transition-all duration-300" />This concept also applies to error messages. Don't store a hasError boolean alongside error: string | null. Just check state.error !== null. Redundant flags are a source of desynchronized bugs. They're also the first thing you'll spend 20 minutes debugging at 11pm wondering why the UI shows an error state when the error string is empty.
Splitting Complex Reducers with combineReducers-Style Composition
What do you do when a reducer grows to 200 lines? Same thing you'd do with a big React component — split it. You don't need Redux's combineReducers. You can compose reducers manually in about five lines.
Create a slice reducer for each logical concern. Then call them from a root reducer, passing the relevant slice of state. Each sub-reducer only sees its own slice. The root reducer assembles the pieces.
function uiReducer(state: UIState, action: AppAction): UIState {
switch (action.type) {
case 'OPEN_MODAL': return { ...state, modalOpen: true };
case 'CLOSE_MODAL': return { ...state, modalOpen: false };
default: return state;
}
}
function dataReducer(state: DataState, action: AppAction): DataState {
switch (action.type) {
case 'FETCH_START': return { ...state, loading: true };
case 'FETCH_DONE': return { ...state, loading: false, items: action.payload };
default: return state;
}
}
function rootReducer(state: AppState, action: AppAction): AppState {
return {
ui: uiReducer(state.ui, action),
data: dataReducer(state.data, action),
};
}This scales further than most people expect. A few dozen action types across three or four slice reducers is entirely manageable without a library. If you do hit a wall — truly global state shared across many unrelated component trees — that's when you'd reach for Zustand or a context-based approach. But that wall is further away than most tutorials imply.
Sharing State with Context Without the Boilerplate
If you need useReducer state accessible across a component tree, pair it with Context. This is the "poor man's Redux" pattern — and I mean that as a compliment. It's simple, it's typed, and it doesn't require you to install or learn anything.
Create a context that holds both state and dispatch. Provide it near the root of the subtree that needs it. Consume it in children with useContext. No selectors, no connect HOCs, no store configuration. Just a context value and a hook.
The pattern works especially well for things like theme state (see the theme toggle implementation for a concrete example) or notification queues. Check out the toast notification patterns article — that's another case where a reducer plus context handles the whole thing without a library.
Does this mean you'll never need Zustand or Jotai? Of course not. Those libraries solve real problems — atom-level subscriptions, state outside React, devtools integration. But reach for them *after* you've hit a genuine limitation, not preemptively because a tutorial said state management requires a package.
Testing Reducers in Isolation
This is the underrated win. A pure reducer is just a function. You test it by calling it with state and an action and checking the return value. No mocking, no rendering, no async setup. Just assertions.
Write unit tests for every action type. Keep them fast and boring. If a reducer has 12 action types, write 12 tests (plus a few edge cases). Each test runs in under 1ms. Your entire reducer test suite runs in under a second.
import { formReducer } from './formReducer';
const initial: FormState = {
step: 1,
loading: false,
error: null,
values: { email: '', plan: 'free' },
};
test('NEXT_STEP increments step', () => {
const next = formReducer(initial, { type: 'NEXT_STEP' });
expect(next.step).toBe(2);
});
test('NEXT_STEP does not go past step 3', () => {
const state = { ...initial, step: 3 as const };
const next = formReducer(state, { type: 'NEXT_STEP' });
expect(next.step).toBe(3);
});
test('SUBMIT_ERROR sets error message and clears loading', () => {
const state = { ...initial, loading: true };
const next = formReducer(state, { type: 'SUBMIT_ERROR', payload: 'Network timeout' });
expect(next.loading).toBe(false);
expect(next.error).toBe('Network timeout');
});You can ship this with confidence because the logic is fully verified in isolation. The component tests just confirm that the right actions get dispatched in response to user interactions — they don't need to re-test the state transitions. That separation is what makes the whole thing maintainable long-term.
FAQ
Reach for useReducer when you have 3+ related pieces of state that change together, when the next state depends on the previous state in non-trivial ways, or when you have enough action types that naming them explicitly would help your team understand what's happening. A single boolean toggle is fine as useState. A multi-step form with async submission and validation is a reducer.
Yes, and it's significantly better with TypeScript than without. Use discriminated unions for your action type — each variant gets its own type with a string literal type field. The switch statement in your reducer then narrows the action type correctly per case, giving you full type safety on the payload without any casting.
Reducers must stay synchronous and pure. Keep async logic in your component event handlers or custom hooks. Dispatch a START action before the async call, then dispatch a SUCCESS or ERROR action in the promise resolution. The reducer handles state transitions; the component handles side effect coordination.
Negligible in practice. Both trigger a re-render when state changes. useReducer doesn't add overhead that matters at the scale most React apps operate at. If you're concerned about unnecessary renders, the fix is the same as with useState: memoize child components with React.memo, split your context so consumers only subscribe to the slice they need, or move high-frequency state closer to the component that owns it.
No. React guarantees that the dispatch function from useReducer has a stable identity — it won't change between renders. You can pass it to child components or include it in dependency arrays without wrapping it in useCallback. This is one of the nice design decisions React made: you get stability for free.
Pass a third argument to useReducer — an init function. React will call init(initialArg) to compute the initial state on first render only. This is useful when computing initial state is expensive (reading from localStorage, parsing a large config object) and you don't want to pay that cost on every render. The signature is: useReducer(reducer, initialArg, init).