WooCommerce Checkout Validation with React Query
You’ve got products loading. Your cart is wired up. Now comes the part that actually ships revenue: the checkout form.
Checkout validation in a headless WooCommerce setup is harder than it looks. There are two distinct validation layers — and conflating them is the most common mistake we see when teams move from a traditional WooCommerce theme to a React front-end. This post walks through both layers using the @atomic-solutions/react-woocommerce package, which exposes useCheckout, useCart, and usePlaceOrder as React Query primitives.
Why Checkout Validation Is Hard in Headless
In a classic WooCommerce theme, PHP handles both validation layers in the same request cycle: the browser submits the form, WooCommerce validates fields and stock server-side, then renders errors back in the same page load. Everything is synchronous from the user’s perspective.
In headless React, those two layers are separate:
-
Client-side validation runs before any network request. It guards against empty required fields, malformed email addresses, mismatched passwords, and anything else you can check locally. This keeps unnecessary requests off the wire and gives instant feedback.
-
Server-side validation runs inside WooCommerce after you call the REST API. It checks stock availability, coupon validity, payment gateway restrictions, address rules, and fraud filters — none of which the browser can know in advance.
Both layers produce errors. The job of your checkout form is to handle both, present them clearly, and avoid submitting until the client-side gate has passed.
Reading Current Checkout State with useCheckout
Before building the form, read what WooCommerce already knows about the current checkout session. The useCheckout hook returns the current server-side checkout state: pre-filled billing/shipping if the user is logged in, applied coupons, selected shipping methods, and any gateway-level metadata.
import { useCheckout, useCart } from '@atomic-solutions/react-woocommerce'
function CheckoutPage() {
const { data: checkout, isLoading } = useCheckout()
const { data: cart } = useCart()
if (isLoading) return <p>Loading checkout…</p>
return (
<CheckoutForm
defaultBilling={checkout?.billing_address}
defaultShipping={checkout?.shipping_address}
cartTotal={cart?.totals.total_price}
/>
)
}
Pre-populating the form from useCheckout matters for returning customers — it reduces friction and lowers the chance they abandon before submitting. The hook is a standard React Query query, so it caches the result and re-fetches in the background automatically.
Building the Form: Client-Side Validation First
You can use React Hook Form, Zod, or plain useState — the usePlaceOrder mutation doesn’t care how you build the form. What matters is that you do not call placeOrder.mutate() until client-side validation passes.
Here is a minimal approach with React Hook Form and inline validation:
import { useForm } from 'react-hook-form'
type CheckoutFormData = {
billing: {
first_name: string
last_name: string
email: string
address_1: string
city: string
postcode: string
country: string
}
shipping: {
first_name: string
last_name: string
address_1: string
city: string
postcode: string
country: string
}
stripeToken: string
}
function CheckoutForm({ defaultBilling, defaultShipping }) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CheckoutFormData>({
defaultValues: {
billing: defaultBilling,
shipping: defaultShipping,
},
})
// handleSubmit runs our callback only when validation passes
const onSubmit = (data: CheckoutFormData) => {
submitOrder(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('billing.email', {
required: 'Email is required',
pattern: { value: /\S+@\S+\.\S+/, message: 'Invalid email' },
})}
placeholder="Email"
/>
{errors.billing?.email && (
<p className="text-red-500">{errors.billing.email.message}</p>
)}
{/* remaining fields */}
</form>
)
}
React Hook Form’s handleSubmit wrapper is the client-side gate. It calls your submit handler only when all registered fields pass their rules. That means usePlaceOrder only fires after the form is locally valid.
Submitting the Order with usePlaceOrder
usePlaceOrder is a React Query mutation. You call placeOrder.mutate(checkoutData) with billing, shipping, payment method, and any gateway-specific payment data. Here is the full submission wiring:
import { usePlaceOrder } from '@atomic-solutions/react-woocommerce'
function CheckoutForm() {
const placeOrder = usePlaceOrder()
const handleSubmit = async (formData: CheckoutFormData) => {
placeOrder.mutate({
billing: formData.billing,
shipping: formData.shipping,
payment_method: 'stripe',
payment_data: [
{ key: 'stripe_source', value: formData.stripeToken },
],
})
}
if (placeOrder.isSuccess) {
return <div>Order #{placeOrder.data.id} placed!</div>
}
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
{placeOrder.isError && (
<p className="text-red-500">{placeOrder.error.message}</p>
)}
<button type="submit" disabled={placeOrder.isPending}>
{placeOrder.isPending ? 'Placing order…' : 'Place order'}
</button>
</form>
)
}
A few things worth noting here:
placeOrder.isPendingistruewhile the mutation is in flight. Disable the submit button during this window — double-submits create duplicate orders and are painful to clean up.placeOrder.isErrorandplaceOrder.error.messagesurface whatever WooCommerce returned in its error response. This is the server-side validation layer surfacing.placeOrder.isSuccessmeans WooCommerce accepted the order. At this pointplaceOrder.data.idcontains the new order ID. Redirect to a confirmation page or render an inline confirmation.
Handling Server-Side Errors from WooCommerce
WooCommerce returns structured error responses from its checkout REST endpoint. Common server-side failures you will encounter in production:
- Stock errors — a cart item went out of stock between the user adding it and submitting the form.
- Coupon errors — the applied coupon expired or hit its usage limit.
- Address validation failures — the shipping zone has no matching rate for the postcode, or the payment gateway blocks certain countries.
- Gateway-specific errors — Stripe declines the card, or a required
payment_datakey is missing.
All of these land in placeOrder.error. The REST endpoint returns a code and message field. If your UX needs to differentiate between a card decline and a stock error, inspect placeOrder.error.code:
{placeOrder.isError && (
<div role="alert" className="checkout-error">
{placeOrder.error.code === 'woocommerce_rest_product_out_of_stock' ? (
<p>
One or more items in your cart are no longer in stock.{' '}
<a href="/cart">Review your cart</a>
</p>
) : (
<p>{placeOrder.error.message}</p>
)}
</div>
)}
For most cases, rendering placeOrder.error.message verbatim is safe — WooCommerce error messages are written for end users. The exception is payment gateway messages: some gateways return technical strings (Stripe’s "Your card's security code is incorrect." is fine; others are not). Review the messages your gateway produces during test mode and add a fallback where needed.
Success State and Order Confirmation
Once placeOrder.isSuccess is true, the mutation data contains the created order object. At a minimum, show the order ID. In a production build, you will also want to:
- Clear the cart (call the cart invalidation method from
useCartor redirect to a clean URL) - Fire your analytics order event with the order ID and cart total
- Redirect to
/order-confirmation?order_id=...for a dedicated thank-you page
if (placeOrder.isSuccess) {
const order = placeOrder.data
// Fire analytics
window.dataLayer?.push({
event: 'purchase',
ecommerce: { transaction_id: order.id, value: order.total },
})
return (
<div>
<h2>Order confirmed</h2>
<p>Order #{order.id} — a confirmation email is on its way to {order.billing.email}.</p>
</div>
)
}
Putting It Together
The pattern is the same whether you are building with React Hook Form, Zod + useForm, or plain controlled inputs:
- Read existing state with
useCheckoutand pre-populate the form. - Validate locally before touching the network.
- Call
placeOrder.mutate()with the composedcheckoutData. - Render
placeOrder.isErrorfor server-side feedback andplaceOrder.isPendingto block re-submission. - Handle
placeOrder.isSuccesswith confirmation UI and analytics.
The @atomic-solutions/react-woocommerce hooks follow standard React Query conventions throughout, so if you are already using React Query elsewhere in your app, there is no new mental model to learn. See the full hook reference at /woocommerce/react-hooks/ for useCheckout, useCart, usePlaceOrder, and the rest of the package surface.
Need help shipping a headless WooCommerce checkout in production? Atomic Solutions builds headless WooCommerce storefronts for clients.