TL;DR
- Reducers are my default upgrade when component state starts behaving like a workflow.
- State machines are worth it when the allowed transitions matter as much as the values.
- The biggest benefit is not abstraction elegance; it is preventing invalid UI states.
- If a screen has retries, partial failures, role-based branches, or async steps, explicit transitions usually save time later.
Context
A lot of React state begins simple and then quietly becomes workflow state:
- an onboarding flow
- a checkout path
- a bulk approval tool
- a modal with validation, retries, and submission
Once the screen has enough branching, useState stops being descriptive enough. The question shifts from “what values do I hold?” to “what transitions are valid?”
The Approach
1. Use a reducer when many events affect the same state
type SaveState = {
status: 'editing' | 'submitting' | 'success' | 'failure'
error: string | null
}
type Action =
| { type: 'submit' }
| { type: 'saved' }
| { type: 'failed'; message: string }
| { type: 'retry' }
function saveReducer(state: SaveState, action: Action): SaveState {
switch (action.type) {
case 'submit':
return { status: 'submitting', error: null }
case 'saved':
return { status: 'success', error: null }
case 'failed':
return { status: 'failure', error: action.message }
case 'retry':
return { status: 'editing', error: null }
}
}
I like this because it centralizes the workflow rules in one place and makes unit testing cheap.
2. Use a state machine when invalid transitions are expensive
Reducers are enough for many screens. I move toward a machine mindset when I need to answer:
- can this event happen in this state?
- what transitions are impossible?
- what side effects happen on entry or exit?
- how do retries and cancellations work?
3. Real-world example: approval workflow
Imagine a review screen where a manager can approve, reject, request changes, or bulk-apply actions to selected items.
The dangerous bugs are usually not “button did nothing.” They are:
- approve clicked twice during retry
- modal closed but mutation still resolves into the old screen
- stale selection used for a bulk action
- screen says success while the backend only partially succeeded
That is exactly where explicit transition logic pays for itself.
4. Keep side effects outside the pure transition logic
function ApprovalScreen() {
const [state, dispatch] = useReducer(approvalReducer, initialState)
async function handleApprove() {
dispatch({ type: 'approveStarted' })
try {
await approveItems(state.selectedIds)
dispatch({ type: 'approveSucceeded' })
} catch (error) {
dispatch({
type: 'approveFailed',
message: error instanceof Error ? error.message : 'Unknown error',
})
}
}
return <ApprovalView state={state} onApprove={handleApprove} />
}
I want the reducer pure and the effect orchestration explicit.
Trade-offs
- Reducers make workflows clearer, but they add ceremony for simple components.
- State-machine thinking prevents invalid states, but it requires discipline and naming clarity.
- Explicit transition models improve testing and debugging, but they can feel heavy if the screen is mostly read-only or linear.
- Teams sometimes over-formalize simple flows; the right level depends on the cost of a wrong transition.