How to build a checkout page in React
A checkout page combines form validation, order summary display, and payment processing into one of the most complex flows in any e-commerce application. As the creator of CoreUI with 25 years of front-end development experience, I’ve built checkout pages for multiple production e-commerce platforms and the key is breaking it into focused sections: shipping, payment, and order review. Each section should be a separate component with its own validation, and the parent page coordinates the multi-step flow. This structure keeps the code manageable and makes it easy to add, remove, or reorder steps.
Build the checkout form with React Hook Form for validation.
// CheckoutForm.jsx
import { useForm } from 'react-hook-form'
export function ShippingForm({ onSubmit }) {
const {
register,
handleSubmit,
formState: { errors }
} = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Full Name</label>
<input
id="name"
{...register('name', { required: 'Name is required' })}
/>
{errors.name && <span>{errors.name.message}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
{...register('email', {
required: 'Email is required',
pattern: { value: /^\S+@\S+$/i, message: 'Invalid email' }
})}
/>
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<label htmlFor="address">Address</label>
<input
id="address"
{...register('address', { required: 'Address is required' })}
/>
{errors.address && <span>{errors.address.message}</span>}
</div>
<button type="submit">Continue to Payment</button>
</form>
)
}
React Hook Form avoids re-renders on every keystroke by using uncontrolled inputs via register. Validation runs on submit by default. errors contains validation messages only for fields that failed.
Order Summary Component
Display cart items and totals during checkout.
// OrderSummary.jsx
import { useCart } from './CartContext'
export function OrderSummary() {
const { items, total } = useCart()
const shipping = total >= 50 ? 0 : 5.99
const tax = total * 0.08
const grandTotal = total + shipping + tax
return (
<div className="order-summary">
<h3>Order Summary</h3>
{items.map(item => (
<div key={item.id} className="order-line">
<span>{item.name} × {item.quantity}</span>
<span>${(item.price * item.quantity).toFixed(2)}</span>
</div>
))}
<hr />
<div className="order-line">
<span>Subtotal</span>
<span>${total.toFixed(2)}</span>
</div>
<div className="order-line">
<span>Shipping</span>
<span>{shipping === 0 ? 'Free' : `$${shipping.toFixed(2)}`}</span>
</div>
<div className="order-line">
<span>Tax (8%)</span>
<span>${tax.toFixed(2)}</span>
</div>
<div className="order-line total">
<strong>Total</strong>
<strong>${grandTotal.toFixed(2)}</strong>
</div>
</div>
)
}
Computing shipping and tax as derived values keeps them in sync with the cart automatically. This component reads from the cart context set up in the shopping cart — no additional state needed.
Multi-Step Checkout Controller
Manage the steps and shared state at the page level.
// CheckoutPage.jsx
import { useState } from 'react'
import { ShippingForm } from './ShippingForm'
import { PaymentForm } from './PaymentForm'
import { OrderSummary } from './OrderSummary'
import { useCart } from './CartContext'
const STEPS = ['Shipping', 'Payment', 'Review']
export default function CheckoutPage() {
const [step, setStep] = useState(0)
const [shippingData, setShippingData] = useState(null)
const { dispatch } = useCart()
function handleShippingSubmit(data) {
setShippingData(data)
setStep(1)
}
async function handlePaymentSubmit(paymentData) {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shipping: shippingData, payment: paymentData })
})
if (res.ok) {
dispatch({ type: 'CLEAR_CART' })
setStep(2)
}
}
return (
<div className="checkout">
<nav>
{STEPS.map((label, i) => (
<span key={label} className={i === step ? 'active' : ''}>
{label}
</span>
))}
</nav>
<OrderSummary />
{step === 0 && <ShippingForm onSubmit={handleShippingSubmit} />}
{step === 1 && <PaymentForm onSubmit={handlePaymentSubmit} />}
{step === 2 && <p>Thank you! Your order has been placed.</p>}
</div>
)
}
The parent page holds no form state — each step owns its own form and calls onSubmit with its data. The page coordinates the step transitions and passes data to the API. Clearing the cart on successful order prevents double-purchases on page refresh.
Best Practice Note
This is the same multi-step pattern used in CoreUI React e-commerce templates. Never process payments directly from the frontend — use a payment provider like Stripe that sends payment intents from your server. See how to integrate Stripe in React for the complete payment flow including server-side intent creation and client-side confirmation.



