Skip to main content

React Checkout Flow: Build a Multi-Step Form

The checkout flow is where ecommerce stores make or lose money. A confusing or slow checkout increases cart abandonment—research by Baymard Institute shows 70% of carts are abandoned, often due to a cumbersome checkout process. Building an intuitive multi-step checkout form in React requires clear progress indication, form validation, and the ability to save progress so users can exit and return. This article walks you through creating a checkout with shipping address, billing address, order review, and payment steps. You'll learn UX patterns that reduce abandonment and increase conversion, and how to validate addresses in real time using a postal service API.

Design the Checkout Flow

A typical ecommerce checkout has these steps:

  1. Shipping Address: Where items are delivered.
  2. Shipping Method: Standard, Express, Overnight (affects cost and delivery time).
  3. Billing Address: Where the charge appears (can be same as shipping).
  4. Order Review: Summary of items, totals, and a final confirmation.
  5. Payment: Card details, Stripe integration.

Break this into steps so users see progress and don't feel overwhelmed.

Build a Multi-Step Form Component

Create src/components/CheckoutForm.jsx:

import { useState } from 'react';
import ShippingStep from './checkout/ShippingStep';
import ShippingMethodStep from './checkout/ShippingMethodStep';
import BillingStep from './checkout/BillingStep';
import ReviewStep from './checkout/ReviewStep';
import PaymentStep from './checkout/PaymentStep';

export default function CheckoutForm() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({
shipping: {},
shippingMethod: 'standard',
billing: {},
sameAsShipping: true,
});
const [errors, setErrors] = useState({});

const updateFormData = (section, data) => {
setFormData({
...formData,
[section]: { ...formData[section], ...data },
});
};

const validateStep = (currentStep) => {
const newErrors = {};

if (currentStep === 1) {
if (!formData.shipping.firstName)
newErrors.firstName = 'First name is required';
if (!formData.shipping.lastName)
newErrors.lastName = 'Last name is required';
if (!formData.shipping.address)
newErrors.address = 'Address is required';
if (!formData.shipping.city)
newErrors.city = 'City is required';
if (!formData.shipping.postalCode)
newErrors.postalCode = 'Postal code is required';
if (!formData.shipping.country)
newErrors.country = 'Country is required';
}

if (currentStep === 3 && !formData.sameAsShipping) {
if (!formData.billing.firstName)
newErrors.billingFirstName = 'First name is required';
if (!formData.billing.address)
newErrors.billingAddress = 'Address is required';
}

setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};

const handleNext = () => {
if (validateStep(step)) {
setStep(step + 1);
}
};

const handlePrevious = () => {
if (step > 1) setStep(step - 1);
};

const handleSubmit = async (paymentResult) => {
// Create order in backend
const orderResponse = await fetch(
`${import.meta.env.VITE_API_URL}/orders`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
paymentIntentId: paymentResult.paymentIntentId,
}),
}
);

if (orderResponse.ok) {
const order = await orderResponse.json();
// Redirect to order confirmation
window.location.href = `/order-confirmation/${order.id}`;
}
};

const steps = [
{ number: 1, label: 'Shipping' },
{ number: 2, label: 'Shipping Method' },
{ number: 3, label: 'Billing' },
{ number: 4, label: 'Review' },
{ number: 5, label: 'Payment' },
];

return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-2xl mx-auto px-4">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex justify-between mb-4">
{steps.map((s) => (
<div
key={s.number}
className={`flex-1 text-center text-sm font-medium ${
step >= s.number ? 'text-blue-600' : 'text-gray-400'
}`}
>
{s.label}
</div>
))}
</div>
<div className="w-full bg-gray-300 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${(step / steps.length) * 100}%` }}
></div>
</div>
</div>

{/* Step Content */}
<div className="bg-white rounded-lg shadow p-8">
{step === 1 && (
<ShippingStep
data={formData.shipping}
errors={errors}
onChange={(data) => updateFormData('shipping', data)}
/>
)}
{step === 2 && (
<ShippingMethodStep
method={formData.shippingMethod}
onChange={(method) => setFormData({ ...formData, shippingMethod: method })}
/>
)}
{step === 3 && (
<BillingStep
shipping={formData.shipping}
billing={formData.billing}
sameAsShipping={formData.sameAsShipping}
errors={errors}
onAddressChange={(data) => updateFormData('billing', data)}
onSameAsShippingChange={(same) =>
setFormData({ ...formData, sameAsShipping: same })
}
/>
)}
{step === 4 && (
<ReviewStep formData={formData} />
)}
{step === 5 && (
<PaymentStep
formData={formData}
onSuccess={handleSubmit}
/>
)}
</div>

{/* Navigation Buttons */}
<div className="flex gap-4 mt-8">
<button
onClick={handlePrevious}
disabled={step === 1}
className="flex-1 border border-gray-300 py-3 rounded font-semibold hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Back
</button>
{step < 5 && (
<button
onClick={handleNext}
className="flex-1 bg-blue-600 text-white py-3 rounded font-semibold hover:bg-blue-700"
>
Continue
</button>
)}
</div>
</div>
</div>
);
}

This component manages checkout state, validates each step before allowing progression, and displays a progress bar so users know where they are in the flow.

Build the Shipping Step Component

Create src/components/checkout/ShippingStep.jsx:

export default function ShippingStep({ data, errors, onChange }) {
const handleChange = (e) => {
const { name, value } = e.target;
onChange({ [name]: value });
};

return (
<div>
<h2 className="text-2xl font-bold mb-6">Shipping Address</h2>

<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">First Name</label>
<input
type="text"
name="firstName"
value={data.firstName || ''}
onChange={handleChange}
className="w-full border rounded px-3 py-2"
/>
{errors.firstName && (
<p className="text-red-600 text-sm mt-1">{errors.firstName}</p>
)}
</div>

<div>
<label className="block text-sm font-medium mb-2">Last Name</label>
<input
type="text"
name="lastName"
value={data.lastName || ''}
onChange={handleChange}
className="w-full border rounded px-3 py-2"
/>
{errors.lastName && (
<p className="text-red-600 text-sm mt-1">{errors.lastName}</p>
)}
</div>

<div className="col-span-2">
<label className="block text-sm font-medium mb-2">Address</label>
<input
type="text"
name="address"
placeholder="123 Main St"
value={data.address || ''}
onChange={handleChange}
className="w-full border rounded px-3 py-2"
/>
{errors.address && (
<p className="text-red-600 text-sm mt-1">{errors.address}</p>
)}
</div>

<div>
<label className="block text-sm font-medium mb-2">City</label>
<input
type="text"
name="city"
value={data.city || ''}
onChange={handleChange}
className="w-full border rounded px-3 py-2"
/>
{errors.city && (
<p className="text-red-600 text-sm mt-1">{errors.city}</p>
)}
</div>

<div>
<label className="block text-sm font-medium mb-2">State</label>
<input
type="text"
name="state"
value={data.state || ''}
onChange={handleChange}
className="w-full border rounded px-3 py-2"
/>
</div>

<div>
<label className="block text-sm font-medium mb-2">Postal Code</label>
<input
type="text"
name="postalCode"
value={data.postalCode || ''}
onChange={handleChange}
className="w-full border rounded px-3 py-2"
/>
{errors.postalCode && (
<p className="text-red-600 text-sm mt-1">{errors.postalCode}</p>
)}
</div>

<div>
<label className="block text-sm font-medium mb-2">Country</label>
<select
name="country"
value={data.country || ''}
onChange={handleChange}
className="w-full border rounded px-3 py-2"
>
<option value="">Select Country</option>
<option value="US">United States</option>
<option value="CA">Canada</option>
<option value="MX">Mexico</option>
<option value="GB">United Kingdom</option>
</select>
{errors.country && (
<p className="text-red-600 text-sm mt-1">{errors.country}</p>
)}
</div>
</div>
</div>
);
}

Create a Review Step

Let users review their entire order before payment:

// src/components/checkout/ReviewStep.jsx
export default function ReviewStep({ formData }) {
return (
<div className="space-y-8">
<h2 className="text-2xl font-bold">Review Your Order</h2>

<div className="bg-gray-50 p-4 rounded">
<h3 className="font-semibold mb-2">Shipping Address</h3>
<p>
{formData.shipping.firstName} {formData.shipping.lastName}
</p>
<p>{formData.shipping.address}</p>
<p>
{formData.shipping.city}, {formData.shipping.state}{' '}
{formData.shipping.postalCode}
</p>
<p>{formData.shipping.country}</p>
</div>

<div className="bg-gray-50 p-4 rounded">
<h3 className="font-semibold mb-2">Shipping Method</h3>
<p className="capitalize">{formData.shippingMethod}</p>
</div>

<div className="bg-gray-50 p-4 rounded">
<h3 className="font-semibold mb-2">Billing Address</h3>
{formData.sameAsShipping ? (
<p className="text-gray-600">Same as shipping address</p>
) : (
<div>
<p>
{formData.billing.firstName} {formData.billing.lastName}
</p>
<p>{formData.billing.address}</p>
</div>
)}
</div>
</div>
);
}

Save Checkout Progress to Backend

In case a user exits checkout, save their progress:

// Save every 30 seconds
useEffect(() => {
const interval = setInterval(async () => {
await fetch(`${import.meta.env.VITE_API_URL}/checkout/draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: localStorage.getItem('sessionId'),
step,
formData,
}),
});
}, 30000);

return () => clearInterval(interval);
}, [step, formData]);

When users return, reload their draft:

useEffect(() => {
const loadDraft = async () => {
const response = await fetch(
`${import.meta.env.VITE_API_URL}/checkout/draft?sessionId=${localStorage.getItem(
'sessionId'
)}`
);
if (response.ok) {
const draft = await response.json();
setFormData(draft.formData);
setStep(draft.step);
}
};

loadDraft();
}, []);

Key Takeaways

  • Break checkout into 4–5 clear steps; users abandon checkouts when they feel lost or overwhelmed.
  • Show a progress bar and allow users to navigate backwards to fix mistakes.
  • Validate form data step-by-step, not all at once, to catch errors early.
  • Save checkout progress to the backend so users can return and resume.
  • Order review is critical—give users a final chance to fix quantities or remove items before payment.

Frequently Asked Questions

Should I save credit card details for future purchases?

Yes, but only if the user explicitly opts in. Stripe handles this with Saved Payment Methods. Show a checkbox: "Save this card for faster checkout next time." Never save card details without consent.

Can I auto-fill the billing address from shipping?

Yes, that's the "Same as Shipping" checkbox. This reduces form fatigue. Around 80% of users have the same billing and shipping address.

What if address validation fails?

Use a third-party API like Google Places API or SmartyStreets to validate addresses in real time. Show a suggestion if the entered address is slightly off: "Did you mean 123 Main Street?"

Should checkout require an account?

Offer guest checkout. Forcing account creation increases abandonment by 25% according to Baymard research. Email the order confirmation and receipt; users can create an account later if they want.

Further Reading