Checkout Flow
A complete e-commerce checkout flow demonstrating multi-step form management with state machines. This example shows how to handle cart management, shipping information, payment processing, and order completion.
Implementation Details
Section titled “Implementation Details”This checkout flow demonstrates several key state machine patterns:
- Multi-step workflow - Cart → Shipping → Payment → Processing → Success/Failure
- Form state management - Input validation and data persistence across steps
- Async processing - Simulated payment processing with success/failure scenarios
- Error handling - Graceful error states with retry capabilities
- Navigation controls - Back/forward navigation between checkout steps
The demo includes sample cart items and simulates payment processing with a 70% success rate.
Source Code
Section titled “Source Code”export type CartData = { items: Array<{ id: string; name: string; price: number; quantity: number }>; total: number;};export type ShippingForm = { address: string; city: string; zipCode: string; error?: string | null;};export type PaymentForm = { cardNumber: string; expiryDate: string; cvv: string; error?: string | null;};export type ShippingData = { cart: CartData; shipping: ShippingForm;};export type PaymentData = { cart: CartData; shipping: ShippingForm; payment: PaymentForm;};export type ProcessingData = { cart: CartData; shipping: ShippingForm; payment: PaymentForm;};export type SuccessData = { cart: CartData; shipping: ShippingForm; payment: PaymentForm; orderId: string;};export type FailedData = { cart: CartData; shipping: ShippingForm; payment: PaymentForm; error: string;};
import { defineStates } from "matchina";import type { CartData, ShippingForm, PaymentForm, ProcessingData, SuccessData, FailedData,} from "./types";
export const states = defineStates({ Cart: (data: CartData) => data, Shipping: ({ shipping = { address: "", city: "", zipCode: "", error: null }, ...rest }: { shipping?: ShippingForm; cart: CartData; }) => ({ shipping, ...rest }), Payment: ({ payment = { cardNumber: "", expiryDate: "", cvv: "", error: null }, ...rest }: { payment?: PaymentForm; cart: CartData; shipping: ShippingForm; }) => ({ payment, ...rest, }), Processing: (data: ProcessingData) => data, Success: (data: SuccessData) => data, Failed: (data: FailedData) => data,});
import { matchina } from "matchina";import { states } from "./states";
export const createCheckoutMachine = () => matchina( states, { Cart: { proceedToShipping: "Shipping", }, Shipping: { proceedToPayment: "Payment", backToCart: "Cart", }, Payment: { placeOrder: "Processing", backToShipping: "Shipping", }, Processing: { success: "Success", failure: "Failed", }, Success: { newOrder: () => states.Cart({ items: [ { id: "1", name: "Wireless Headphones", price: 99.99, quantity: 0, }, { id: "2", name: "Bluetooth Speaker", price: 49.99, quantity: 0 }, ], total: 0, }), }, Failed: { retry: "Payment", backToCart: "Cart", }, }, states.Cart({ items: [ { id: "1", name: "Wireless Headphones", price: 99.99, quantity: 1 }, { id: "2", name: "Bluetooth Speaker", price: 49.99, quantity: 2 }, ], total: 199.97, }) );
export type CheckoutMachine = ReturnType<typeof createCheckoutMachine>;
import React, { useState } from "react";import type { CheckoutMachine } from "./machine";import type { CartData, PaymentData, ShippingData } from "./types";
function getMissing(fields: Record<string, string>) { return Object.entries(fields) .filter(([_, v]) => !v.trim()) .map(([k]) => k);}
export function ShippingForm({ data, machine,}: { data: ShippingData; machine: CheckoutMachine;}) { const { cart, shipping = { address: "", city: "", zipCode: "", error: null }, } = data; const [address, setAddress] = useState(shipping.address || ""); const [city, setCity] = useState(shipping.city || ""); const [zipCode, setZipCode] = useState(shipping.zipCode || ""); React.useEffect(() => { setAddress(shipping.address || ""); setCity(shipping.city || ""); setZipCode(shipping.zipCode || ""); }, [shipping.address, shipping.city, shipping.zipCode]);
const missingFields = getMissing({ address, city, zipCode });
return ( <div> <h2 className="text-2xl font-bold mb-6">Shipping Information</h2> {shipping.error && ( <div className="mb-4 p-3 border border-red-400 text-red-700 rounded"> {shipping.error} </div> )} {missingFields.length > 0 && ( <div className="mb-4 p-3 border border-yellow-400 text-yellow-900 rounded bg-yellow-50"> <span className="font-semibold">Missing:</span>{" "} {missingFields.join(", ")} </div> )} <div className="space-y-4 mb-6"> <div> <label className="block text-sm font-medium mb-1">Address</label> <input type="text" value={address} onChange={(e) => setAddress(e.target.value)} className={`w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 border-current/20 ${missingFields.includes("address") ? "border-yellow-400" : ""}`} placeholder="123 Main Street" /> </div> <div className="grid grid-cols-2 gap-4"> <div> <label className="block text-sm font-medium mb-1">City</label> <input type="text" value={city} onChange={(e) => setCity(e.target.value)} className={`w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 border-current/20 ${missingFields.includes("city") ? "border-yellow-400" : ""}`} placeholder="New York" /> </div> <div> <label className="block text-sm font-medium mb-1">ZIP Code</label> <input type="text" value={zipCode} onChange={(e) => setZipCode(e.target.value)} className={`w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 border-current/20 ${missingFields.includes("zipCode") ? "border-yellow-400" : ""}`} placeholder="10001" /> </div> </div> </div> <div className="flex space-x-4"> <button onClick={() => machine.backToCart(cart)} className="flex-1 px-4 py-2 rounded border border-current/20 text-current hover:bg-current/10" > Back to Cart </button> <button onClick={() => { if (missingFields.length > 0) return; machine.proceedToPayment({ cart, shipping: { address, city, zipCode }, }); }} className="flex-1 px-4 py-2 rounded border border-current/20 text-current hover:bg-current/10" disabled={missingFields.length > 0} > Continue to Payment </button> </div> </div> );}
export function PaymentForm({ data, machine, handleAsyncProcessing,}: { data: PaymentData; machine: CheckoutMachine; handleAsyncProcessing: (data: PaymentData) => void;}) { const { cart, shipping, payment } = data; const [cardNumber, setCardNumber] = useState(payment.cardNumber || ""); const [expiryDate, setExpiryDate] = useState(payment.expiryDate || ""); const [cvv, setCvv] = useState(payment.cvv || ""); React.useEffect(() => { setCardNumber(payment.cardNumber || ""); setExpiryDate(payment.expiryDate || ""); setCvv(payment.cvv || ""); }, [payment.cardNumber, payment.expiryDate, payment.cvv]);
const missingFields = getMissing({ cardNumber, expiryDate, cvv });
return ( <div> <h2 className="text-2xl font-bold mb-6">Payment Information</h2> {payment.error && ( <div className="mb-4 p-3 border border-red-400 text-red-700 rounded"> {payment.error} </div> )} {missingFields.length > 0 && ( <div className="mb-4 p-3 border border-yellow-400 text-yellow-900 rounded bg-yellow-50"> <span className="font-semibold">Missing:</span>{" "} {missingFields.join(", ")} </div> )} <div className="mb-6 p-4 rounded border border-current/10"> <h3 className="font-semibold mb-2">Shipping Address</h3> <p>{shipping.address}</p> <p> {shipping.city}, {shipping.zipCode} </p> </div> <div className="space-y-4 mb-6"> <div> <label className="block text-sm font-medium mb-1">Card Number</label> <input type="text" value={cardNumber} onChange={(e) => setCardNumber(e.target.value)} className={`w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 border-current/20 ${missingFields.includes("cardNumber") ? "border-yellow-400" : ""}`} placeholder="1234 5678 9012 3456" /> </div> <div className="grid grid-cols-2 gap-4"> <div> <label className="block text-sm font-medium mb-1"> Expiry Date </label> <input type="text" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} className={`w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 border-current/20 ${missingFields.includes("expiryDate") ? "border-yellow-400" : ""}`} placeholder="MM/YY" /> </div> <div> <label className="block text-sm font-medium mb-1">CVV</label> <input type="text" value={cvv} onChange={(e) => setCvv(e.target.value)} className={`w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 border-current/20 ${missingFields.includes("cvv") ? "border-yellow-400" : ""}`} placeholder="123" /> </div> </div> </div> <div className="border-t pt-4 mb-6 border-current/10"> <div className="flex justify-between text-xl font-bold"> <span>Total: ${cart.total?.toFixed(2) ?? "0.00"}</span> </div> </div> <div className="flex space-x-4"> <button onClick={() => machine.backToShipping({ cart, shipping, }) } className="flex-1 px-4 py-2 rounded border border-current/20 text-current hover:bg-current/10" > Back to Shipping </button> <button onClick={() => { if (missingFields.length > 0) return; handleAsyncProcessing({ cart, shipping, payment: { cardNumber, expiryDate, cvv }, }); }} className="flex-1 px-4 py-2 rounded border border-current/20 text-current hover:bg-current/10" disabled={missingFields.length > 0} > Place Order </button> </div> </div> );}
export function CartForm({ data, machine,}: { data: CartData; machine: CheckoutMachine;}) { const [items, setItems] = useState(data.items);
const handleQuantityChange = (id: string, quantity: number) => { setItems((prev) => prev.map((item) => item.id === id ? { ...item, quantity: Math.max(0, quantity) } : item ) ); };
const total = items.reduce( (sum, item) => sum + item.price * item.quantity, 0 );
return ( <div> <h2 className="text-2xl font-bold mb-6">Shopping Cart</h2> <div className="space-y-4 mb-6"> {items.map((item) => ( <div key={item.id} className="flex justify-between items-center p-4 border rounded border-current/10" > <div> <h3 className="font-semibold">{item.name}</h3> <p className="opacity-70">${item.price.toFixed(2)} each</p> </div> <div className="flex items-center space-x-2"> <label className="mr-2">Qty:</label> <input type="number" min={0} value={item.quantity} onChange={(e) => handleQuantityChange(item.id, Number(e.target.value)) } className="w-16 px-2 py-1 border border-current/20 rounded text-center" /> <span className="font-semibold"> ${(item.price * item.quantity).toFixed(2)} </span> </div> </div> ))} </div> <div className="border-t pt-4 mb-6 border-current/10"> <div className="flex justify-between text-xl font-bold"> <span>Total: ${total.toFixed(2)}</span> </div> </div> {total <= 0 ? ( <div className="text-center text-yellow-700 mb-4"> Your cart is empty. Add items to proceed. </div> ) : ( <button onClick={() => machine.proceedToShipping({ cart: { items, total } })} className="w-full px-4 py-2 rounded border border-current/20 text-current hover:bg-current/10" disabled={total <= 0} > Proceed to Shipping </button> )} </div> );}
import { useMachine } from "matchina/react";import { CartForm, PaymentForm, ShippingForm } from "./forms";import type { CheckoutMachine } from "./machine";
export const CheckoutView = ({ machine }: { machine: CheckoutMachine }) => { useMachine(machine); const currentState = machine.getState();
// Simulate async processing for payment const handleAsyncProcessing = async (data: any) => { machine.placeOrder(data); setTimeout(() => { if (Math.random() > 0.3) { const orderId = "ORD-" + Math.random().toString(36).substring(2, 9).toUpperCase(); machine.success({ ...data, orderId }); } else { machine.failure({ ...data, error: "Payment failed. Please try again.", }); } }, 2000); };
return ( <div className="max-w-2xl mx-auto rounded-lg border border-current/20 p-6"> {currentState.match({ Cart: (data) => <CartForm data={data} machine={machine} />, Shipping: (data) => <ShippingForm data={data} machine={machine} />, Payment: (data) => ( <PaymentForm data={data} machine={machine} handleAsyncProcessing={handleAsyncProcessing} /> ), Processing: (_data) => ( <div className="text-center"> <h2 className="text-2xl font-bold mb-4">Processing Order...</h2> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-current mx-auto mb-4"></div> <p className="opacity-70"> Please wait while we process your payment... </p> </div> ), Success: (data) => ( <div className="text-center"> <div className="mb-6"> <div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 border-4 border-green-500"> <svg className="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> </svg> </div> <h2 className="text-2xl font-bold text-green-600 mb-2"> Order Successful! </h2> <p className="opacity-70 mb-4">Order ID: {data.orderId}</p> </div> <div className="border border-green-200 rounded p-4 mb-6"> <p className="text-green-800"> Your order has been placed successfully. You will receive a confirmation email shortly. </p> </div> <button onClick={() => machine.newOrder()} className="px-4 py-2 rounded border border-current/20 text-current hover:bg-current/10" > Place Another Order </button> </div> ), Failed: (data) => ( <div className="text-center"> <div className="mb-6"> <div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 border-4 border-red-500"> <svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> </svg> </div> <h2 className="text-2xl font-bold text-red-600 mb-2"> Order Failed </h2> <p className="opacity-70 mb-4">{data.error}</p> </div> <div className="flex space-x-4 justify-center"> <button onClick={() => machine.retry(data)} className="px-4 py-2 rounded border border-current/20 text-current hover:bg-current/10" > Try Again </button> <button onClick={() => machine.backToCart(data.cart)} className="px-4 py-2 rounded border border-current/20 text-current hover:bg-current/10" > Back to Cart </button> </div> </div> ), })} </div> );};
import { useMemo } from "react";import { CheckoutView } from "./CheckoutView";import { createCheckoutMachine } from "./machine";
export function CheckoutDemo() { const machine = useMemo(createCheckoutMachine, []); return <CheckoutView machine={machine} />;}
How It Works
Section titled “How It Works”The checkout flow uses a single state machine to manage the entire process:
- Cart State - Display items and total, allow proceeding to shipping
- Shipping State - Collect shipping address with validation
- Payment State - Collect payment information and display order summary
- Processing State - Handle async payment processing with loading state
- Success/Failed States - Show completion status with appropriate actions
Each state maintains the necessary data and provides clear navigation options, ensuring users can only perform valid actions at each step.
Benefits of State Machine Checkout
Section titled “Benefits of State Machine Checkout”Using a state machine for checkout flows provides several advantages:
- Clear Progress - Users always know where they are in the process
- Data Persistence - Form data is preserved when navigating between steps
- Error Recovery - Failed payments can be retried without losing information
- Validation - Each step can validate data before allowing progression
- Predictable Flow - Impossible states are prevented by design
Next Steps
Section titled “Next Steps”This checkout example demonstrates practical state machine usage for e-commerce. You might want to explore:
- Authentication Flow - User login and registration patterns
- State Effects Guide - Managing side effects and async operations