useReducer for Complex State Logic
When a component's state updates start depending on each other, useState turns into a maintenance problem. useReducer centralizes the logic, and combining it with context gives you a lightweight global store without Redux.
At some point, useState stops working as a tool and starts working against you.
You have a multi-step enrollment flow. Three components need to read the current state. Two of them can trigger transitions.
You are threading onSubmit, flightOptions, isSearching, and selectedId through four layers of props. When the user goes back to the search step, the results disappear -- because the results lived in local state that unmounted.
This is the problem useReducer is designed to solve.
The Reducer Shape
A reducer is a pure function: it takes the current state and an event, and returns the next state. That is the whole contract.
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SEARCH_SUBMITTED': return { ...state, status: 'loading', searchParams: action.params };
case 'RESULTS_RECEIVED': return { ...state, status: 'results', courses: action.courses };
case 'BACK_PRESSED': return state.status === 'results' ? { ...state, status: 'idle' } : state;
case 'SEARCH_FAILED': return { ...state, status: 'error' };
default: return state;
}
}All the logic is in one place. All state updates go through one function. You can read the reducer and understand every possible transition without opening a component file.
Modeling the State First
Before writing the reducer, write the state shape. The transcript from state diagrams and modeling applies here: draw the states and transitions first.
For a course enrollment flow:
idle → loading (user submits search)
loading → results (courses received)
loading → error (fetch failed)
results → idle (user presses back)
results → confirmed (user selects and confirms)This maps directly into a discriminated union:
type EnrollmentState =
| { status: 'idle' }
| { status: 'loading'; query: string }
| { status: 'results'; query: string; courses: Course[] }
| { status: 'error'; query: string; message: string }
| { status: 'confirmed'; courseId: string; courseName: string };
type EnrollmentAction =
| { type: 'SEARCH_SUBMITTED'; query: string }
| { type: 'RESULTS_RECEIVED'; courses: Course[] }
| { type: 'SEARCH_FAILED'; message: string }
| { type: 'COURSE_SELECTED'; courseId: string; courseName: string }
| { type: 'BACK_PRESSED' };And the reducer:
function enrollmentReducer(
state: EnrollmentState,
action: EnrollmentAction
): EnrollmentState {
switch (action.type) {
case 'SEARCH_SUBMITTED':
return { status: 'loading', query: action.query };
case 'RESULTS_RECEIVED':
if (state.status !== 'loading') return state;
return { status: 'results', query: state.query, courses: action.courses };
case 'SEARCH_FAILED':
if (state.status !== 'loading') return state;
return { status: 'error', query: state.query, message: action.message };
case 'COURSE_SELECTED':
return { status: 'confirmed', courseId: action.courseId, courseName: action.courseName };
case 'BACK_PRESSED':
return state.status === 'results' ? { status: 'idle' } : state;
default:
return state;
}
}
const initialState: EnrollmentState = { status: 'idle' };Notice RESULTS_RECEIVED guards against the wrong state: if we are not in loading, the action is ignored. This is exactly the kind of impossible-state protection that the finite states post described.
Wiring It Up: useReducer
Using it in a component is one line:
const [state, dispatch] = useReducer(enrollmentReducer, initialState);state is the current state. dispatch sends an event to the reducer. The component re-renders with the new state.
For the fetch side effect, useEffect watches the status:
useEffect(() => {
if (state.status !== 'loading') return;
let cancelled = false;
fetchCourses(state.query)
.then(courses => {
if (!cancelled) dispatch({ type: 'RESULTS_RECEIVED', courses });
})
.catch(err => {
if (!cancelled) dispatch({ type: 'SEARCH_FAILED', message: err.message });
});
return () => { cancelled = true; };
}, [state.status, state.query]);The effect runs when status becomes 'loading' -- not based on a boolean flag, not based on a missing dependency. The state machine tells you when to run the effect.
Sharing State: Context as the Pipe
The reducer lives in one component. To share it with sibling components -- without threading props through every level -- wrap it in a context.
type EnrollmentContext = {
state: EnrollmentState;
dispatch: (action: EnrollmentAction) => void;
};
const EnrollmentCtx = createContext<EnrollmentContext>(null as any);function EnrollmentProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(enrollmentReducer, initialState);
// Side effect: fetch when entering 'loading' status
useEffect(() => {
if (state.status !== 'loading') return;
let cancelled = false;
fetchCourses(state.query)
.then(courses => { if (!cancelled) dispatch({ type: 'RESULTS_RECEIVED', courses }); })
.catch(err => { if (!cancelled) dispatch({ type: 'SEARCH_FAILED', message: err.message }); });
return () => { cancelled = true; };
}, [state.status]);
return (
<EnrollmentCtx value={{ state, dispatch }}>
{children}
</EnrollmentCtx>
);
}Any component inside EnrollmentProvider can read the state and dispatch events:
function SearchForm() {
const { dispatch } = use(EnrollmentCtx);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const query = new FormData(e.currentTarget).get('query') as string;
dispatch({ type: 'SEARCH_SUBMITTED', query });
}
return <form onSubmit={handleSubmit}><input name="query" /><button>Search</button></form>;
}
function CourseResults() {
const { state, dispatch } = use(EnrollmentCtx);
if (state.status !== 'results') return null;
return (
<ul>
{state.courses.map(c => (
<li key={c.id}>
{c.name}
<button onClick={() => dispatch({ type: 'COURSE_SELECTED', courseId: c.id, courseName: c.name })}>
Enroll
</button>
</li>
))}
</ul>
);
} ExpandDispatch arrow going into reducer function, reducer returning new state, state flowing through context provider to three child components that each call dispatch
You just built a lightweight Redux. One state object, one reducer, context as the pipe.
Step Navigation: Graph vs Array
Multi-step flows often start as an array:
const steps = ['search', 'loading', 'results', 'confirm', 'complete'];
const [stepIndex, setStepIndex] = useState(0);
function nextStep() { setStepIndex(i => i + 1); }
function prevStep() { setStepIndex(i => i - 1); }This works until the flow is not linear. Optional steps. Steps that branch. Steps you cannot go back from. Now you start adding conditionals inside nextStep and prevStep:
function nextStep() {
if (stepIndex === 3) return; // can't go past confirm if not confirmed
setStepIndex(i => i + 1);
}Those conditionals belong in the flow definition, not scattered across functions.
A directed graph makes the valid transitions explicit:
const stepGraph = {
search: { next: 'loading', back: null },
loading: { next: 'results', back: 'search' },
results: { next: 'confirm', back: 'search' }, // back skips loading
confirm: { next: 'complete', back: 'results' },
complete: { next: null, back: null }, // terminal -- no transitions
} as const;
type StepId = keyof typeof stepGraph;
const [currentStep, setCurrentStep] = useState<StepId>('search');
function goNext() {
const next = stepGraph[currentStep].next;
if (next) setCurrentStep(next);
}
function goBack() {
const back = stepGraph[currentStep].back;
if (back) setCurrentStep(back);
}Every question that was hidden in the array approach is now answered explicitly in the graph. The complete state has next: null -- that is a deliberate decision, not an accidental gap. The results back arrow goes to search directly, skipping loading -- modeled in one line.
When flows branch or have optional steps, a directed graph scales where an array does not.
A Note on Context Performance
Context re-renders every consumer whenever the state changes. For a multi-step enrollment flow where transitions happen on deliberate user actions, this is fine. The state changes a handful of times per session.
It becomes a problem when the state changes rapidly: a timer, a mouse position tracker, a WebSocket subscription that fires many times per second. If you notice components re-rendering more than expected, that is the sign to reach for Zustand, Redux Toolkit, or another library that allows components to subscribe to a slice of state rather than the whole object.
The rule: context is the right tool when state changes infrequently. When state changes many times per second, use a library that supports selective subscriptions.
The Essentials
useReducercentralizes state logic into one testable function. All transitions go through the reducer. The reducer is a plain TypeScript function with no React imports -- you can test it with a simple function call.useReducer+createContextis a lightweight global store. The context is the pipe. Any component inside the provider can read state and dispatch events. The state persists across component unmounts.- For multi-step flows, a directed graph beats an array of steps. Declare which transitions are valid in the data structure, not in conditional branches scattered across handler functions. Terminal states and skip-back arrows become explicit decisions.
Further Reading and Watching
- Scaling Up with Reducer and Context -- React Docs: The official walkthrough of combining useReducer with context -- the pattern described in this post, from the source.
- useReducer -- React Docs: Reference docs for the hook itself, including the full API, lazy initialization, and when to choose it over useState.
- React useReducer Hook Tutorial -- Codevolution: A practical walkthrough of useReducer from simple counter examples through complex state transitions. Note: verify this YouTube link before publishing.
Keep reading