A checkout that branches, waits and goes back
Carts with nothing to ship skip the address step, the address is checked with the server before payment, and the review step links back to edit it. Back has to return the customer to where they came from.
By hand
- The route is an
ifchain insidenext(), with the branch for carts that ship nothing buried in it. back()works the route out again in reverse, so it forgets how the customer got there. Back after Edit address goes tocart, notreview.next()awaits the address check and then moves, even if the customer pressed Back in the meantime. A request that hangs never ends.loadinganderrorare set by hand around the one await, in the right order, for every step that does work.
type Step = 'cart' | 'address' | 'payment' | 'review'type Checkout = { hasPhysicalItems: boolean; address: string | null }
// checkout.ts: the flow, as a small store the page subscribes toexport function createCheckout(validateAddress: (address: string | null) => Promise<boolean>) { let state = { step: 'cart' as Step, checkout: { hasPhysicalItems: true, address: null } as Checkout, loading: false, error: null as unknown, } const listeners = new Set<() => void>() const set = (patch: Partial<typeof state>) => { state = { ...state, ...patch } listeners.forEach((listener) => listener()) }
async function next() { if (state.step === 'cart') { set({ step: state.checkout.hasPhysicalItems ? 'address' : 'payment' }) } else if (state.step === 'address') { set({ loading: true, error: null }) try { const ok = await validateAddress(state.checkout.address) // The user may have pressed Back meanwhile: this moves them to payment anyway if (ok) set({ step: 'payment' }) } catch (error) { set({ error }) } finally { // No timeout: if the request hangs, loading stays true set({ loading: false }) } } else if (state.step === 'payment') { set({ step: 'review' }) } }
// The route again, backwards, kept in step with next() by hand function back() { if (state.step === 'review') set({ step: 'payment' }) else if (state.step === 'payment') set({ step: state.checkout.hasPhysicalItems ? 'address' : 'cart' }) else if (state.step === 'address') set({ step: 'cart' }) }
// "Edit address" on the review step. Back from there goes to cart, not to review. function editAddress() { if (state.step === 'review') set({ step: 'address' }) }
return { getState: () => state, subscribe: (listener: () => void) => { listeners.add(listener) return () => listeners.delete(listener) }, next, back, editAddress, }}With journey
- Each step lists where its
nextevent can go. Candidates are tried in order, and the first whosewhenpasses wins. - The machine records the timeline, and
goToPreviousStepwalks it: Back after Edit address returns toreview. - The check is the event's
run, awaited while the machine holds its position, with its owntimeoutMs.commitstages the answer and the guard reads it, so guards never become async — and navigation is refused while the check is in flight, so the page disables its buttons onloadinginstead of racing it. - The machine keeps them:
currentStep.async.isLoadingwhile the check runs, and the same step'serrorif it throws or times out.
import { withGraphTypes } from '@rxova/journey-core'
type Step = 'cart' | 'address' | 'payment' | 'review'type Checkout = { hasPhysicalItems: boolean; address: string | null; addressValid: boolean }
// One declaration point for the flow's types. `results` pins what the address// check returns, so `commit` below sees a boolean instead of `unknown`.type CheckoutBag = { context: Checkout stepId: Step events: { type: 'next' } | { type: 'editAddress' } results: { next: boolean }}
// checkout.ts: the flow, as a machine the page subscribes toexport function createCheckout(validateAddress: (address: string | null) => Promise<boolean>) { const checkout = withGraphTypes<CheckoutBag>()({ initial: 'cart', context: { hasPhysicalItems: true, address: null, addressValid: false }, steps: { cart: { // Tried in order: the first candidate whose `when` passes wins. on: { next: [ { to: 'address', when: ({ context }) => context.hasPhysicalItems }, { to: 'payment' }, ], }, }, address: { on: { // `run` is awaited while the machine holds its position; `commit` stages // the answer, and the guard reads it — so the check decides the route // without the guard ever becoming async. A false answer, a throw or the // timeout leaves the customer on address, kept as the step's error. next: { label: 'validate address', timeoutMs: 5000, run: ({ snapshot }) => validateAddress(snapshot.context.address), commit: ({ result, updateContext }) => updateContext((previous) => ({ ...previous, addressValid: result })), candidates: [{ to: 'payment', when: ({ context }) => context.addressValid }], }, }, }, payment: { on: { next: 'review' } }, review: { on: { editAddress: 'address' } }, }, }) checkout.controls.start() return checkout}
// footer.ts: what the buttons read and callexport function footer(checkout: ReturnType<typeof createCheckout>) { const { currentStep, history } = checkout.getSnapshot() return { loading: currentStep?.async.isLoading ?? false, error: currentStep?.async.error ?? null, canGoBack: history.canGoBack, // Refused while the check is in flight, so a page disables these on `loading` // rather than letting a click race it. next: () => checkout.send('next'), // Walks the recorded timeline: after "Edit address", Back returns to review. back: () => checkout.navigate.goToPreviousStep(), editAddress: () => checkout.send('editAddress'), }}