Multi-step flows in web applications often end up with state that looks like this:
const [showForm, setShowForm] = useState(true);
const [showConfirmation, setShowConfirmation] = useState(false);
const [showOtp, setShowOtp] = useState(false);
const [showSurvey, setShowSurvey] = useState(false);
Multi-step flows appear in registration, checkout, password recovery, payment authorization, and many other parts of an application. Representing each phase with a separate boolean is easy to reach for, but with a trade-off.. as it also allows the state model to represent combinations that should never occur.
Four independent booleans give you sixteen possible combinations, and out of them the flow actually has maybe four valid states, whereas the other twelve are impossible states: confirmation and OTP open at the same time, a survey before verification, or everything false and the user staring at a blank screen. Every new boolean doubles the combinations and adds more defensive if logic to keep the UI from breaking.
And the React documentation recommends avoiding contradictions in state, and sets an example with two flags, isSending and isSent:
If you forget to call
setIsSentandsetIsSendingtogether, you may end up in a situation where bothisSendingandisSentaretrueat the same time. The more complex your component is, the harder it is to understand what happened. [1]
Model the flow as one state
And when the phases of a flow are mutually exclusive, the current phase can be represented as a single value:
type FlowState =
| { step: 'form' }
| { step: 'confirmation'; draft: PaymentDraft }
| { step: 'otp'; draft: PaymentDraft; transactionId: string }
| { step: 'survey'; transactionId: string };
And this approach does two things.
First, contradictory states disappear. otp and survey can’t both be active, one value can’t be two variants.
Second, the data requirements are enforced by the type system. The otp state requires a transactionId, so TypeScript rejects any attempt to create that state without one.
This is what it means to make impossible states impossible. The XState docs use the same example: a form can’t be “filling” and “submitting” at the same time, because they are finite states, not independent flags [3].
Centralizing transition logic
And an explicit state model also makes it possible to define which transitions are valid:
otp → confirmation ✅ allowed
survey → otp ❌ not allowed
That distinction matters in financial flows. Going back from OTP to review the details may be valid, while returning to OTP after the operation has already completed may not be.
A discriminated union makes the valid state shapes explicit, but it does not by itself restrict how the application moves between them. Any handler can still call setState with another valid variant.
When those transition rules become important, they can be centralized in a reducer. Actions describe what happened, and the reducer determines whether that event produces a new state [2].
function flowReducer(state: FlowState, action: FlowAction): FlowState {
switch (state.step) {
case 'confirmation':
if (action.type === 'BACK') {
return { step: 'form' };
}
if (action.type === 'OTP_REQUIRED') {
return {
step: 'otp',
draft: state.draft,
transactionId: action.transactionId,
};
}
return state;
case 'otp':
if (action.type === 'OTP_VERIFIED') {
return { step: 'survey', transactionId: state.transactionId };
}
return state;
default:
return state;
}
}
Transition logic is now centralized in one place. If an action does not apply to the current state, the reducer leaves the state unchanged.
I want to be clear that this is the documented recommendation, for me, I mostly used the union type plus a coordinator component that advances the step after the mutation succeeds. React recommends reaching for a reducer when scattered updates start causing bugs, and for a four-step flow with one path through it, I didn’t reach that point.
Keep flow state and request state separate
Flow state describes the current phase of the user journey, while TanStack Query describes the lifecycle of a request. These states can overlap: the user may remain on the OTP step while a verification request is pending.
Keeping the two concerns separate avoids duplicating request state in the flow model. In this setup, the flow advances only after the corresponding server operation succeeds.
const createPayment = useMutation({
mutationFn: submitPayment,
onSuccess: ({ transactionId }) => {
setFlow({ step: 'otp', draft, transactionId });
},
});
The mutation’s isPending state controls the loading UI, while the flow state determines which phase is rendered.
Visualizing the model
I did not use a state-machine library in the production implementation. While writing this article, however, I recreated the flow in Stately Studio to visualize its transitions.
The diagram made some aspects of the flow easier to inspect, particularly the paths around asynchronous operations. In the machine, operations such as creating the transaction and verifying the OTP are represented as states, with success and failure determining the next transition. This is a more complete model of the workflow than the smaller UI state used in the application.
The simulation also makes the allowed paths explicit. For example, success cannot be reached without passing through OTP verification.
For this implementation, I did not find XState necessary. The flow was small enough that a discriminated union, together with the existing request state from TanStack Query, was sufficient. A state-machine library becomes more useful as the transition model grows. For example, when the flow contains nested or parallel states, guards, retries, cancellation, or enough transitions that keeping them distributed across application code becomes difficult to reason about.
Conclusion
Adding another boolean is often the smallest local change, but it can make the overall state model harder to reason about when several flags represent mutually exclusive phases of the same flow.
Booleans are appropriate for genuinely independent binary facts. But when several values are different answers to the same question, represent that concept as a single state instead. If each state requires different data, a discriminated union can encode those requirements in the type system. Transition logic can remain local while the flow is simple, and be centralized in a reducer or state machine when it becomes really difficult to reason about.
Further Reading
- React — Choosing the State Structure: avoiding contradictory and redundant state
- React — Extracting State Logic into a Reducer: centralizing transitions as actions
- Stately — Finite States: why a machine is in exactly one state at a time
- Stately — State Machines and Statecharts: the formal model behind all of this