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
goToNextStepcan go. Candidates are tried in order, and the first whosewhenpasses wins. - The machine records the history, and
goToPreviousStepwalks it: Back after Edit address returns toreview. - The check is the transition's
when, with atimeoutMs. Calls run one at a time, so a Back pressed during the check waits for it. - The machine keeps them:
async.isLoadingwhile the check runs, and the step'serrorif 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 toexport 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 callexport 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'), }}