Rxova
← journey

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

  1. The route is an if chain inside next(), with the branch for carts that ship nothing buried in it.
  2. back() works the route out again in reverse, so it forgets how the customer got there. Back after Edit address goes to cart, not review.
  3. next() awaits the address check and then moves, even if the customer pressed Back in the meantime. A request that hangs never ends.
  4. loading and error are 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 to
export 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

  1. Each step lists where its next event can go. Candidates are tried in order, and the first whose when passes wins.
  2. The machine records the timeline, and goToPreviousStep walks it: Back after Edit address returns to review.
  3. The check is the event's run, awaited while the machine holds its position, with its own timeoutMs. commit stages 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 on loading instead of racing it.
  4. The machine keeps them: currentStep.async.isLoading while the check runs, and the same step's error if 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 to
export 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 call
export 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'),
}
}