Skip to content
Go back

Stop Managing Multi-Step React Flows With Booleans

Edit page

Multi-step flows are everywhere: registration, checkout, password recovery, payment authorization. And most of them, at some point, end up looking like this:

const [showConfirmation, setShowConfirmation] = useState(false);
const [showOtp, setShowOtp] = useState(false);
const [otpVerified, setOtpVerified] = useState(false);
const [showSurvey, setShowSurvey] = useState(false);

This isn’t just hard to read. The state model itself is wrong.

Four independent booleans give you sixteen possible combinations. The flow has maybe four valid states. The other twelve are impossible situations: 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.

The React docs actually warn about exactly this — they call it contradictory and redundant state, and recommend strturing state so that invalid combinations can’t be represented in the first place [1].

Model the flow as one state

A multi-step flow is in exactly one state at a time. So model it that way:

type FlowState =
  | { step: 'form' }
  | { step: 'confirmation'; draft: PaymentDraft }
  | { step: 'otp'; draft: PaymentDraft; transactionId: string }
  | { step: 'survey'; transactionId: string };

Two things happen here.

First, contradictory states disappear. otp and survey can’t both be active — one value can’t be two variants.

Second, and this is the part I like, the data requirements are now enforced by the compiler. The OTP step cannot exist without a transactionId. You can’t render verification for a transaction the server never created. That bug moved from “caught in QA, hopefully” to “doesn’t compile.”

This is what people mean by making impossible states impossible. The XState docs use almost the same example: a form can’t be “filling” and “submitting” at the same time, because the are finite states, not independent flags [3].

Transitions become rules, not flag-flipping

Once states are named, navigation stops being guesswork about which flags to reset. It becomes a rule you write down:

otp → confirmation      ✅ allowed
completed → otp         ❌ not allowed

That second rule matters in financial flows. Going back from OTP to review the details is fine. Going back after completion is not — the server-side operation already happened. With booleans, nothing stops you. With an explicit model, the transition simply doesn’t exist.

A reducer centralizes these rules in one reviewable place [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 stateult:
      return state;
  }
}

Invalid transitions aren’t handled with conditions. They’re just absent.

Flow state is not server state

This is where most articles stop, and it’s the part that actually bites in production.

Your flow state answers: where is the user? TanStack Query answers: what is happening with the request?

These are different questions. A user can be on the OTP screen while a verification request is still pending. Don’t merge them.

The rule I follow: advance the flow only after the server confirms the transition.

const createPayment = useMutation({
  mutationFn: submitPayment,
  onSuccess: ({ transactionId }) => {
    dispatch({ type: 'OTP_REQUIRED', transactionId });
  },
});

The mutation’s isPending drives the spinner. The flow state drives navigation. The UI can lag behind the server, but it can never get ahead of it.

Visualizing the model

The production implementation doesn’t use a state-machine library — it’s the discriminated union and reduceabove. But while writing this article, I rebuilt the same transition model in Stately Studio, and it was genuinely useful: the diagram exposes missing cases before they show up in component code.

The visual version also makes the async operations explicit. “Creating the transaction” and “verifying the OTP” are themselves states, with success and failure transitions — not booleans sitting next to the flow:

Try it: fire the events and attempt to reach success without going through OTP. You can’t. That’s the whole argument of this article, running in front of you.

Do you need XState in production for this? For a small sequential flow — no. A union type and a r cover it with zero dependencies. The library earns its place when you have parallel states, complex guards, or machines shared across teams. The point isn’t the runtime. It’s the model — and the model survives either implementation choice.

The lesson

Booleans are fine for independent binary facts. A multi-step flow is not a collection of independent facts — it’s a sequence of valid states connected by controlled transitions.

I used to add a boolean for every new requirement because it was the smallest possible change. I wasn’t modeling the flow. I was modeling symptoms of it, one flag at a time.

The cheapest place to prevent a bug is in the shape of the state itself.


Further Reading

  1. React — Choosing the State Structure: avoiding contradictory and redundant state
  2. React — Extracting State Logic into a Reducer: centralizing transitions as actions
  3. Stately �States](https://stately.ai/docs/finite-states): why a machine is in exactly one state at a time
  4. Stately — State Machines and Statecharts: the formal model behind all of this

Edit page
Share this post on:

Next Post
Cloning NFC Room Cards with ESP32 and PN532