If you’ve ever shopped online, you’re probably familiar with the multiple steps that it takes to check out. First, you fill out your basic contact information; then, your shipping address; next, your payment information and billing address; and finally, review and submit! And the process can get even longer and more complex depending on what you’re ordering. For example, pet medication. At what point do you add information about your pets? Your veterinarian’s contact information? These are some of the many questions that came up when we designed Koala Health’s checkout process.

Koala Health is a new Boston-based start-up that has all the medications and health products that your pet needs. Koala is unique in offering medication that’s individually packaged by dose, date, and time. No more counting pills from bottles or setting reminders. It’s simple, and we hoped to make the checkout process just as painless.

What Makes a Good Checkout Process?

When initially strategizing about the checkout form we were going to build, we worked with the Koala Health team to establish some guidelines:

  1. It had to be fast. Koala wanted folks to get from shopping cart to order placed in record time.
  2. It had to be painless. Folks can’t get stuck on one step for hours or they won’t be able to purchase.
  3. It had to collect all the information that the Koala team needed. Because we were building a brand new service with specific legal requirements, we had to collect some information that would be otherwise unconventional in a, say, online clothing shopping experience.

With these goals in mind, we started by investigating the user experience. What steps would a new user need to walk through? What steps would an existing user need to walk through? We looked at dozens of checkout flows to spot common patterns and landed on the following flow:

Screens showing the flow

To Shopify or Not? That is the Question.

You’ve probably come across a Stripe or Shopify checkout before. These are top-of-the-line checkout forms if you’re looking for something off the shelf. However, being a plug-and-play option also means that they lacked the customization capabilities that we needed in order to ask for pet and vet information.

We decided to build it ourselves. Plus, we’d already identified a few places in the storefront where multi-step forms were needed—namely, the product selection experience. If we could build one multi-step form flow, we could reuse that pattern across all the different areas that needed it.

State Machines and You

If you think about a multi-step form in the most generic of terms, it really is just a finite-state machine! A finite-state machine is a model of behavior that says an object “[…] can be in exactly one of a finite number of states at any given time” [1]. The easiest example of a finite-state machine is a stoplight. A stoplight has three states: red, yellow, and green. A stoplight can only ever be in one of those states at a time, but is able to transition between them based on a set of conditions—red becomes green when cars can pass, green becomes yellow when cars should slow down, and yellow becomes red again when cars should stop.

In the case of our checkout form, each of the steps is a state: contact information, shipping address, pets and vets, billing, and review are all states in our machine. A user can only be in one state at a time, allowing them to focus on the task at hand. Once a condition has passed (i.e., we’ve gathered all the necessary information from the user), we can transition to the next state for the user to enter the next set of information.

Once we defined our model, it was time to get to building! Koala’s storefront is built with TypeScript, a strongly-typed programming language that provides end-to-end type safety for any business logic that we write. We also decided to bring in XState, a TypeScript library purpose-built for designing state machines.

Designing the State Machine

In XState, there are three concepts that you need to know to build a type-safe state machine: context, events, and typestates.

Context

Each state in our machine has associated data with it. For example, the basicInfo state may be collecting name and email information, whereas the shippingAddress state collects street, city, and state information. This associated data is called context.

export interface BasicInfoContext {
	name: string;
	email: string;
}

export interface ShippingAddressContext {
	street1: string;
	street2?: string;
	city: string;
	state: string;
	zipcode: string;
}

// ...

export interface CheckoutContext {
	basicInfo?: BasicInfoContext;
	shippingAddress?: ShippingAddressContext;
}

Events

To transition from one state to another, XState calls for the use of events: signals to the machine that it’s time to move on. These events can bring data with them, such as is the case with our Confirm Basic Info Event, which has the context we gathered from the basicInfo state.

export interface ConfirmBasicInfoEvent {
	type: 'CONFIRM_BASIC_INFO';
	value: BasicInfoContext;
}

export interface ConfirmShippingAddressEvent {
	type: 'CONFIRM_SHIPPING_ADDRESS';
	value: ShippingAddressContext;
}

// ...

export type CheckoutEvent = ConfirmBasicInfoEvent | ConfirmShippingAddressEvent;

Typestates

A typestate defines the current form of the machine’s global context in a given state. For example, in our basicInfo state, all the context data is optional because we haven’t collected anything yet! However, when the user is in the shippingAddress state, they should have at least the basicInfo context data.

export interface BasicInfoTypestate {
	value: 'basicInfo';
	context: CheckoutContext;
}

export interface ShippingAddressTypestate {
	value: 'shippingAddress';
	context: CheckoutContext & Required<Pick<CheckoutContext, 'basicInfo'>>;
}

// ...

export interface CheckoutTypestate {
	basicInfo: BasicInfoTypestate;
	shippingAddress: ShippingAddressTypestate;
}

Putting It All Together

Now that we have our types defined, it’s time to assemble the state machine!

Setting the Initial State

Our initial machine has no context data and so everything will be left undefined. Then, we define all our states and the events that they respond to.

// features/Checkout/state.ts

import { assign, createMachine } from 'xstate';

export const checkoutMachine = createMachine<
	CheckoutContext, CheckoutEvent, CheckoutTypestate[keyof CheckoutTypestate]
>({
	id: 'checkout',
	context: {
		basicInfo: undefined,
		shippingAddress: undefined,
		// ...
	},
	// Start at the basic info state
	initial: 'basicInfo',
	states: {
		basicInfo: {
			on: {
				CONFIRM_BASIC_INFO: {
					actions: assign((ctx, evt) => ({ ...ctx, basicInfo: evt.value })),
					// Transition to the `shippingAddress` state next
					target: 'shippingAddress',
				}
			}
		},
		shippingAddress: {
			on: {
				CONFIRM_SHIPPING_ADDRESS: {
					actions: assign((ctx, evt) => ({ ...ctx, shippingAddress: evt.value })),
					// Transition to the `pets` state next
					target: 'pets',
				}
			}
		},
		// ...
	}
});

Scaffolding the React Component

Next, let’s put together our Checkout component. Using XState’s useMachine hook, we can get the current state of the machine and a send function for sending events to the machine. The state.matches function will compare the machine’s current state with the given one and render the respective screen if truthy.

// features/Checkout/index.tsx

import { checkoutMachine } from './state';
import { BasicInfoScreen } from './01-BasicInfoScreen';
// ...

export function Checkout() {
	const [state, send] = useMachine(checkoutMachine);

  if (state.matches('basicInfo')) {
    return <BasicInfoScreen state={state} onSubmit={event => send(event)} />;
  } else if (state.matches('shippingAddress')) {
    return <ShippingAddressScreen state={state} onSubmit={event => send(event)} />;
  } else if (state.matches('pets')) {
    // ...
  } else {
    throw new Error('Form has entered unknown state.');
  }
}

Rendering the First Screen

Finally, we can render our screens. In the below example, the BasicInfoScreen component takes in the BasicInfoTypestate to get initial form values, renders a Formik form that collects name and email information, and sends along a CONFIRM_BASIC_INFO_EVENT on form submission.

// features/Checkout/01-BasicInfoScreen.tsx

import { BasicInfoContext, BasicInfoTypestate, ConfirmBasicInfoEvent } from './state';

export interface BasicInfoScreenProps {
  state: BasicInfoTypestate;
  onSubmit(event: ConfirmBasicInfoEvent): void;
}

export function BasicInfoScreen({ state, onSubmit }: BasicInfoScreenProps) {
	// Define the initial values of the form using machine context
	const initialValues: BasicInfoContext = {
		name: state.context.basicInfo?.name ?? '',
		email: state.context.basicInfo?.email ?? '',
	};

	return (
		<div>
			<h3>Basic info</h3>
			<Formik<BasicInfoContext>
				initialValues={initialValues}
				onSubmit={values => {
					// Submit the `CONFIRM_BASIC_INFO` event on form submission
					onSubmit({
						type: 'CONFIRM_BASIC_INFO',
						value: {
							name: values.name,
							email: values.email
						}
					});
				}}>
				<Form>
					<Field name="name" />
					<Field name="email" />
				</Form>
			</Formik>
		</div>
	);
}

Did We Do It?

Let’s take a step back to review the guidelines we had set out for us.

  1. It had to be fast. React and XState provide near-instant updates to our multi-step form as the user makes changes.
  2. It had to be painless. By providing proper and immediate form validation, folks can fill out the form without any issue.
  3. It had to collect all the information that the Koala team needed. Since we are able to now build our own multi-step forms, we can design it to collect whatever information we need!

So yes, we did it! The Koala team has a new checkout process that collects everything they need, along with a prime example for replicating a multi-step form pattern for future flows.

“Check out” (😉) the full source code of the above example, and be sure to order products for your pet from Koala!

If you want to know more about the entire Koala project, there are details in our case study.