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 goToNextStep can go. Candidates are tried in order, and the first whose when passes wins.
  2. The machine records the history, and goToPreviousStep walks it: Back after Edit address returns to review.
  3. The check is the transition's when, with a timeoutMs. Calls run one at a time, so a Back pressed during the check waits for it.
  4. The machine keeps them: async.isLoading while the check runs, and the step's error if it throws or times out.
import { createJourneyMachine } from '@rxova/journey-core'
type Step = 'cart' | 'address' | 'payment' | 'review'
type Checkout = { hasPhysicalItems: boolean; address: string | null }
// checkout.ts: the flow, as a machine the page subscribes to
export function createCheckout(validateAddress: (address: string | null) => Promise<boolean>) {
const checkout = createJourneyMachine<Checkout, Step>({
initial: 'cart',
context: { hasPhysicalItems: true, address: null },
steps: { cart: {}, address: {}, payment: {}, review: {} },
transitions: {
cart: {
// Tried in order: the first candidate whose `when` passes wins.
goToNextStep: [
{ to: 'address', when: ({ context }) => context.hasPhysicalItems },
{ to: 'payment' },
],
},
address: {
// Moves only once the check resolves true. False, a throw or the timeout
// leave the customer on address; a throw or timeout is kept as the step's error.
goToNextStep: [
{ to: 'payment', timeoutMs: 5000, when: ({ context }) => validateAddress(context.address) },
],
},
payment: { goToNextStep: [{ to: 'review' }] },
review: { goToStepById: [{ to: 'address' }], completeJourney: true },
},
})
void checkout.startJourney()
return checkout
}
// footer.ts: what the buttons read and call
export function footer(checkout: ReturnType<typeof createCheckout>) {
const { async, history } = checkout.getSnapshot()
return {
loading: async.isLoading,
error: async.byStep.address.error,
canGoBack: history.index > 0,
// Calls run one at a time: a Back pressed during the check waits for it.
next: () => checkout.goToNextStep(),
// Walks the recorded history: after "Edit address", Back returns to review.
back: () => checkout.goToPreviousStep(),
editAddress: () => checkout.goToStepById('address'),
}
}