EmpireUI
Get Pro
← Blog9 min read#checkout#form#react

Checkout Form in React: Address, Payment, Review Steps with Validation

Build a multi-step checkout form in React with address, payment, and review stages — complete with Zod validation, Stripe-ready fields, and clean UX patterns.

React code editor showing a checkout form component with validation

Why Multi-Step Checkout Forms Are Harder Than They Look

You'd think a checkout form is just a few inputs wired to a submit handler. Slap an email field, a card number, done. In practice, that approach collapses fast — users drop off, validation fires at the wrong moment, and the back button breaks everything. Multi-step checkout exists because it converts better, not because developers love building it.

The pattern has three jobs: collect shipping info, collect payment info, and let the user review before charging them. Each step needs its own validation scope. You can't validate card details on step one, and you can't let someone reach the review screen with a malformed ZIP. Getting that scoping right is where most implementations go sideways.

Honestly, the biggest mistake I see is treating the whole form as one giant React state blob. When you do that, validation errors bleed across steps, rerenders hammer unrelated fields, and the mental model for the next dev inheriting your code becomes a nightmare. Step isolation is your friend from day one.

Worth noting: this guide targets React 18 with react-hook-form v7 and Zod for schema validation. If you're on older versions some of the resolver API has changed — specifically the way useForm accepts resolver was updated in v7.0 (2021) and has been stable since.

Project Structure and State Architecture

Before writing a single input, think about where state lives. You need two things: the current step index, and an accumulated payload that gets built up across steps. A context or a simple parent component both work — I'd lean toward a flat parent component with prop drilling unless your checkout has more than four steps.

type CheckoutData = {
  address: AddressFields | null;
  payment: PaymentFields | null;
};

type Step = 'address' | 'payment' | 'review';

const STEPS: Step[] = ['address', 'payment', 'review'];

export function CheckoutForm() {
  const [currentStep, setCurrentStep] = useState<number>(0);
  const [data, setData] = useState<CheckoutData>({ address: null, payment: null });

  const goNext = (stepData: Partial<CheckoutData>) => {
    setData(prev => ({ ...prev, ...stepData }));
    setCurrentStep(s => Math.min(s + 1, STEPS.length - 1));
  };

  const goBack = () => setCurrentStep(s => Math.max(s - 1, 0));

  return (
    <div>
      <StepIndicator steps={STEPS} current={currentStep} />
      {STEPS[currentStep] === 'address' && <AddressStep onNext={goNext} defaults={data.address} />}
      {STEPS[currentStep] === 'payment' && <PaymentStep onNext={goNext} onBack={goBack} defaults={data.payment} />}
      {STEPS[currentStep] === 'review' && <ReviewStep data={data} onBack={goBack} />}
    </div>
  );
}

Each child step component owns its own useForm instance. That's the key move. When the user clicks "Back" from the payment step, you pass the previously submitted values back in as defaultValues so the form repopulates. No global form state, no stale field references.

The StepIndicator component is pure UI. Give it 48px of vertical space minimum and show the active step with a distinct color. If you want something polished out of the box, the stepper-component-react breakdown covers progress indicators with animation you can drop straight in.

Step 1: Address Form with Zod Validation

Address validation has two parts: format checking (is this a valid ZIP?) and presence checking (did they fill it in?). Zod handles both cleanly. The schema goes in its own file so you can share it with any server-side validation you're doing.

import { z } from 'zod';

export const addressSchema = z.object({
  firstName: z.string().min(1, 'First name is required'),
  lastName: z.string().min(1, 'Last name is required'),
  email: z.string().email('Enter a valid email'),
  address1: z.string().min(5, 'Street address too short'),
  address2: z.string().optional(),
  city: z.string().min(2, 'City is required'),
  state: z.string().length(2, 'Use 2-letter state code'),
  zip: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
  country: z.string().default('US'),
});

export type AddressFields = z.infer<typeof addressSchema>;
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

type AddressStepProps = {
  onNext: (data: { address: AddressFields }) => void;
  defaults: AddressFields | null;
};

export function AddressStep({ onNext, defaults }: AddressStepProps) {
  const { register, handleSubmit, formState: { errors } } = useForm<AddressFields>({
    resolver: zodResolver(addressSchema),
    defaultValues: defaults ?? {},
  });

  return (
    <form onSubmit={handleSubmit(d => onNext({ address: d }))}>
      <div className="grid grid-cols-2 gap-4">
        <Field label="First Name" error={errors.firstName?.message}>
          <input {...register('firstName')} />
        </Field>
        <Field label="Last Name" error={errors.lastName?.message}>
          <input {...register('lastName')} />
        </Field>
      </div>
      <Field label="Email" error={errors.email?.message}>
        <input type="email" {...register('email')} />
      </Field>
      <Field label="Address" error={errors.address1?.message}>
        <input {...register('address1')} />
      </Field>
      {/* city / state / zip row */}
      <div className="grid grid-cols-3 gap-4">
        <Field label="City" error={errors.city?.message}>
          <input {...register('city')} />
        </Field>
        <Field label="State" error={errors.state?.message}>
          <input {...register('state')} maxLength={2} />
        </Field>
        <Field label="ZIP" error={errors.zip?.message}>
          <input {...register('zip')} />
        </Field>
      </div>
      <button type="submit">Continue to Payment</button>
    </form>
  );
}

The Field wrapper is a small helper that renders a label, the children, and an error message. Keep it to 30 lines max. Inline all the error styling there so none of the step components need to think about it.

Look, the ZIP regex covers standard 5-digit and ZIP+4 formats. If you're building international checkout you'll need to rethink the whole state/ZIP section — but for a US-only store this covers 99% of addresses without pulling in a full address library.

Step 2: Payment Fields (Stripe Elements or Manual)

There are two paths here. If you're using Stripe.js with @stripe/react-stripe-js, the card number, expiry, and CVC fields are rendered inside iframes by Stripe — you don't touch those values at all. If you're using a different processor or building a prototype, you manage the fields yourself. Both patterns fit the same step architecture.

// With Stripe Elements
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js';

export function PaymentStep({ onNext, onBack }: PaymentStepProps) {
  const stripe = useStripe();
  const elements = useElements();
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) return;
    setLoading(true);
    const card = elements.getElement(CardElement);
    if (!card) return;
    // You'd create a PaymentMethod here, pass the id to your API
    const { error: stripeError, paymentMethod } = await stripe.createPaymentMethod({
      type: 'card',
      card,
    });
    if (stripeError) {
      setError(stripeError.message ?? 'Card error');
      setLoading(false);
      return;
    }
    // Store only the non-sensitive summary
    onNext({ payment: { last4: paymentMethod.card?.last4 ?? '', brand: paymentMethod.card?.brand ?? '' } });
    setLoading(false);
  };

  return (
    <form onSubmit={handleSubmit}>
      <CardElement options={{ style: { base: { fontSize: '16px' } } }} />
      {error && <p className="text-red-500 mt-2">{error}</p>}
      <div className="flex gap-3 mt-4">
        <button type="button" onClick={onBack}>Back</button>
        <button type="submit" disabled={loading}>
          {loading ? 'Processing...' : 'Review Order'}
        </button>
      </div>
    </form>
  );
}

One more thing — never store raw card numbers in React state, even temporarily. Stripe Elements handles the sensitive data entirely client-side within their iframe sandbox. What you pass to your parent component is just a paymentMethodId or a display summary like { brand: 'visa', last4: '4242' }. That's it.

Quick aside: if you want the card inputs to match a dark glassmorphism theme, the CardElement accepts a style prop that takes Stripe's own style object — not CSS classes. You'd replicate your backdrop-filter look by adjusting backgroundColor, color, and iconColor on the Stripe style API. Check out glassmorphism form design for the visual direction if you're building something that needs to look premium.

For the payment step schema when you're rolling your own fields (PCI-DSS implications aside, useful for prototypes), a Zod schema with .regex(/^\d{16}$/) on card number and a Luhn check function is enough to give users real-time feedback before they hit submit.

Step 3: Review Screen and Final Submission

The review step is read-only. No inputs, no validation, just a summary of what's about to happen. Users need to see: shipping address, the last four of the card, the items they're buying, the total. Give every section an "Edit" link that calls setCurrentStep on the parent to jump back to the right step.

type ReviewStepProps = {
  data: CheckoutData;
  onBack: () => void;
  onSubmit: () => Promise<void>;
};

export function ReviewStep({ data, onBack, onSubmit }: ReviewStepProps) {
  const [loading, setLoading] = useState(false);

  const handleConfirm = async () => {
    setLoading(true);
    await onSubmit();
    setLoading(false);
  };

  return (
    <div className="space-y-6">
      <section>
        <h3>Shipping Address</h3>
        <p>{data.address?.firstName} {data.address?.lastName}</p>
        <p>{data.address?.address1}</p>
        <p>{data.address?.city}, {data.address?.state} {data.address?.zip}</p>
      </section>
      <section>
        <h3>Payment</h3>
        <p>{data.payment?.brand?.toUpperCase()} ending in {data.payment?.last4}</p>
      </section>
      <div className="flex gap-3">
        <button onClick={onBack}>Back</button>
        <button onClick={handleConfirm} disabled={loading}>
          {loading ? 'Placing Order...' : 'Place Order'}
        </button>
      </div>
    </div>
  );
}

The confirm button should be disabled during submission and ideally show a spinner. Don't just gray it out without visual feedback — that's the moment users are most anxious. A 200ms delay before showing the spinner avoids the flash on fast connections.

After a successful submission you'd redirect to a /order-confirmation page, not just show a toast. Toast messages disappear. A full confirmation page with an order ID the user can screenshot or bookmark is always the right call for a checkout flow.

In practice, you'll also want optimistic error handling at this stage. If the charge fails (declined card, network timeout), redirect back to the payment step with a pre-populated error message — don't make the user type their address again. Preserve the data.address state, only clear the payment method ID.

Step Indicator and Progress UI

A step indicator doesn't need to be fancy. Three circles connected by a line, with filled vs outlined states for completed and upcoming steps. What it does need is proper accessibility: aria-current="step" on the active item, descriptive labels that screen readers can announce.

type StepIndicatorProps = {
  steps: string[];
  current: number;
};

export function StepIndicator({ steps, current }: StepIndicatorProps) {
  return (
    <ol className="flex items-center gap-0" aria-label="Checkout progress">
      {steps.map((step, i) => (
        <li key={step} className="flex items-center">
          <span
            className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium
              ${i < current ? 'bg-indigo-600 text-white' : ''}
              ${i === current ? 'border-2 border-indigo-600 text-indigo-600' : ''}
              ${i > current ? 'border-2 border-gray-300 text-gray-400' : ''}`}
            aria-current={i === current ? 'step' : undefined}
          >
            {i < current ? '✓' : i + 1}
          </span>
          <span className="ml-2 text-sm capitalize">{step}</span>
          {i < steps.length - 1 && <div className="w-12 h-px bg-gray-300 mx-3" />}
        </li>
      ))}
    </ol>
  );
}

Keep the connector line at exactly 1px — anything thicker pulls too much visual weight away from the step labels. The 48px minimum touch target for each step circle matters on mobile; 32px (w-8) is borderline, so consider bumping to 40px on small screens.

That said, if you want to go further with animated transitions between steps, Framer Motion's AnimatePresence with mode="wait" gives you a clean slide effect without much code. The framer-motion-advanced article has the exact pattern for enter/exit animations on swapped components.

One quick thing worth calling out: if you're building this inside a broader design system and want pre-built form inputs that already handle focus rings, error states, and dark mode — browse components at Empire UI and check the glassmorphism components for a frosted-glass form aesthetic that works well in checkout contexts.

Validation Timing, Error UX, and Accessibility

When do you validate? Not on every keystroke for an email field — that's how you get an error on j before the user has typed john@. Use mode: 'onBlur' in useForm for most fields, and switch to mode: 'onChange' only after the first failed submission. react-hook-form calls this reValidateMode, and it's a one-liner: useForm({ mode: 'onBlur', reValidateMode: 'onChange' }).

Error messages belong next to the field, not in a toast, not in a banner at the top. A 14px red message immediately below the input, with a 4px gap, is the pattern that gets the highest fix rate in user testing. Make sure the input itself gets a red border (not just a red message) — color alone isn't accessible, but combined with the message it reinforces the signal.

Connect error messages to their inputs with aria-describedby. The Field wrapper component should handle this automatically: give the error <p> an id and pass that id into the input's aria-describedby prop. Screen reader users need this to understand what's wrong without having to hunt around the DOM.

How do you handle a flaky network at checkout? Build in retry logic on the confirmation request — three attempts with exponential backoff is standard. Show a clear "Something went wrong, please try again" state with a retry button rather than silently failing. Lost orders are worse than slow orders. For a good reference on accessible error states in forms, react-accessibility-guide covers the ARIA patterns in depth.

FAQ

Should I use one useForm instance for the whole checkout or one per step?

One per step. It keeps validation scoped, prevents cross-step error bleed, and makes each step independently testable. The parent component accumulates the submitted data.

How do I preserve form data when the user navigates back to a previous step?

Pass the previously submitted values as defaultValues when the step mounts. Store completed step data in the parent and pass it down as a prop each time the step renders.

Can I use Stripe Elements inside a multi-step form?

Yes. Mount the Elements provider above your checkout form in the tree so it's available on the payment step. The CardElement and useStripe hook work fine inside a child component.

What's the right validation mode in react-hook-form for a checkout form?

Use mode: 'onBlur' with reValidateMode: 'onChange'. This avoids premature errors while typing and gives fast feedback once the user has already submitted once and is fixing mistakes.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Address Form in React: Country Select, Postcode Validation, AutofillCredit Card Form in React: Flip Animation, Input Mask, ValidationStripe Integration in React + Next.js: Checkout, Webhooks, SubscriptionsReact Hook Form + Zod: The Form Stack That Finally Makes Sense