Day 49: Design: E-Commerce Checkout Flow with State Machines
Model a multi-step checkout flow as an explicit finite state machine to eliminate the impossible-state bugs ad-hoc boolean flags create.
Study
Concepts
Why boolean flags fail for multi-step flows
A checkout flow modeled with independent booleans (`isLoading`, `hasError`, `isPaymentProcessing`, `isSuccess`) allows IMPOSSIBLE combinations the code was never designed for — `isLoading && isSuccess` both true simultaneously is a state your reducer/component can accidentally reach through a bug, and every consumer of that state has to defensively guess which combination is "real". A state machine instead defines a finite, named set of MUTUALLY EXCLUSIVE states (`idle`, `validatingCart`, `enteringPayment`, `processingPayment`, `success`, `failed`) plus explicit transitions between them — the impossible combinations are structurally unrepresentable, not just avoided by convention.
This matters most exactly in checkout because the cost of a bug is directly financial: double-charging a card, showing "success" after a failed payment, or allowing a user to resubmit a payment already in flight are the kinds of bugs a state machine's explicit transition table prevents by construction (a `SUBMIT_PAYMENT` event is simply not defined as valid from the `processingPayment` state, so a double-click cannot fire it twice).
Modeling with XState (or the same idea, hand-rolled)
A library like XState makes the state chart explicit and visualizable: each state lists which events it accepts and which state each event transitions to, with side effects (API calls) modeled as invoked services attached to a state, whose resolution/rejection fires a further transition — this keeps "what can happen next" fully declarative and inspectable, rather than scattered across scattered `if` conditions in event handlers.
Even without adopting a library, the DISCIPLINE of designing the state chart on paper first — enumerating every state, every valid event per state, and every transition — catches edge cases (what happens if the network drops during `processingPayment`? what if the cart changes while the user is on the payment step?) before they become production incidents, which is exactly the mindset a system design interview is testing for.
See It
Visualizations
Visualization
Checkout as an explicit state machine
cart review, "Checkout" button available
stock + price re-check before payment
form is editable; SUBMIT_PAYMENT is the only forward event
form locked; no event can trigger a second charge
terminal or retryable states — never both true at once
Build It
Code Examples
A checkout state machine with XState
import { createMachine, assign } from 'xstate';
const checkoutMachine = createMachine({
id: 'checkout',
initial: 'idle',
context: { error: null },
states: {
idle: { on: { START_CHECKOUT: 'validatingCart' } },
validatingCart: {
invoke: {
src: 'validateCart',
onDone: 'enteringPayment',
onError: { target: 'failed', actions: assign({ error: (_, e) => e.data }) },
},
},
enteringPayment: { on: { SUBMIT_PAYMENT: 'processingPayment' } },
// Notice: SUBMIT_PAYMENT is NOT a valid event here — a double
// click while processing simply does nothing, by construction.
processingPayment: {
invoke: {
src: 'chargeCard',
onDone: 'success',
onError: { target: 'failed', actions: assign({ error: (_, e) => e.data }) },
},
},
success: { type: 'final' },
failed: { on: { RETRY: 'enteringPayment' } },
},
});Consuming it in a component — no boolean-flag juggling
import { useMachine } from '@xstate/react';
function Checkout() {
const [state, send] = useMachine(checkoutMachine, {
services: {
validateCart: () => api.validateCart(),
chargeCard: (ctx, event) => api.chargeCard(event.paymentDetails),
},
});
if (state.matches('processingPayment')) return <ProcessingSpinner />;
if (state.matches('success')) return <OrderConfirmed />;
if (state.matches('failed')) return (
<ErrorBanner message={state.context.error} onRetry={() => send('RETRY')} />
);
if (state.matches('enteringPayment')) {
return <PaymentForm onSubmit={(details) => send({ type: 'SUBMIT_PAYMENT', paymentDetails: details })} />;
}
return <CartReview onCheckout={() => send('START_CHECKOUT')} />;
}Remember
Key Takeaways
- Independent boolean flags allow impossible state combinations; a state machine makes them structurally unrepresentable.
- In checkout specifically, impossible states translate directly into financial bugs — double charges, false "success", resubmitted payments.
- A state chart lists, per state, exactly which events are valid — an invalid event (double-click SUBMIT_PAYMENT while processing) is simply a no-op.
- Design the state chart on paper FIRST, enumerating every state/event/transition, before writing any implementation.
- Side effects (API calls) attach to specific states as invoked services, keeping "what happens next" fully declarative.
Do It
Practice
- 1Draw the full state chart (on paper) for a checkout flow that also supports an "apply promo code" step and a "select shipping method" step.
- 2Implement the XState checkout machine above against mocked validateCart/chargeCard services, including the failed → retry path.
- 3Identify one impossible state your current or a past project's boolean-flag-based flow could reach, and redesign it as a state chart.