Why customers abandon forms, and how Zod and React Hook Form fix it

13 min read

Expedia found $12 million a year in one optional form field. Why customers abandon forms and how Zod and React Hook Form fix most of the causes.

The form is where the sale happens

In 2010, Expedia described how its analytics team had traced a stream of failed bookings to one optional field on the checkout form, labelled "Company".

Some customers read it as the company behind their card and typed in the name of their bank. Then they entered the bank's address as well. When the payment was checked against the cardholder's address, it didn't match, and the booking failed at the very last step. Expedia removed the field. Joe Megibow, then Expedia's vice president of analytics and optimisation, said the change was worth about $12 million a year.

Nothing was wrong with the code. The field worked exactly as it was built. It simply cost more than anyone realised, and the only way to see that was to look at the form one field at a time.

Ask three people in the same company what a form is for, and you'll get three answers.

Marketing sees conversion. Every visitor who gives up halfway through a form is paid-for traffic thrown away. Raising the share of people who finish, known as conversion rate optimisation or CRO, is often cheaper than buying more traffic.

Sales sees leads. A form that accepts a mistyped phone number produces a lead nobody can call back. A form that asks too little produces leads nobody wants to call.

Engineering sees validation, performance and data, and is usually the only team that can actually change how the form behaves.

They are all right, and they often want opposite things: marketing wants fewer fields, sales wants more. This article looks at forms through all three lenses, and at how two open-source libraries, Zod and React Hook Form, let engineering give each team what it needs.

It also looks past the first sale. The forms customers fill in later decide whether they stay: updating a card that has expired, or cancelling a subscription. When a payment fails and the form to fix it is clumsy, a customer who never meant to leave is lost anyway. That kind of churn is among the easiest to prevent, and among the easiest to miss.

From 2000 to today

The forms of 2000 reloaded the whole page to tell you, in red at the top, what you had got wrong, and often cleared half of your answers while doing it. Today a form can check each field as you go, keep everything you typed and fill in your address with one tap. The tools have changed completely. What hasn't changed is how easily a form can lose a customer, as Expedia's "Company" field shows.

What each team measures

A form is one of the most measurable parts of a business, and each team watches different numbers:

  • Marketing: the start rate (visitors who begin the form), the completion rate (those who finish) and the cost per conversion.
  • Sales: the share of leads that can actually be contacted, and the time from submission to first contact.
  • Engineering: the error rate by field, failed submissions, and how quickly the form responds on a phone.
  • Retention: failed payments that are recovered, and the reasons customers give when they cancel.

Most companies track only one number: submissions. The answers to "why" are in the others. Expedia's problem was invisible in the total number of bookings. It only showed up in the failures, traced back to one field.

What makes people give up

The causes are remarkably consistent:

  • Being told off too early. A field that turns red while you are still typing your email address feels like being interrupted mid-sentence.
  • Vague errors. "Invalid input" gives no clue what the form wants.
  • Losing what you typed. Nothing ends a purchase faster than having to fill in a form twice.
  • Accepted here, rejected there. The browser accepts the input, the server rejects it after submit, and the message makes no sense.
  • A form that lags on a phone. A long form that can't keep up with your thumbs feels broken.
  • Doing work the browser could do. Typing a full address that autofill would have entered in one tap.

Every one of these has a technical cause, and a technical fix.

React Hook Form: speed and the right moment for feedback

React Hook Form is a library for building forms in React, the most widely used framework for web interfaces. It addresses the first, third and fifth causes: the ones marketing sees as people who start a form and never finish it.

For the business: customers see errors at the right moment, after they finish a field rather than while they type. The form never clears their input. When they press the button, the first problem is highlighted automatically. And long forms stay responsive on inexpensive phones.

Under the hood: earlier React form libraries, such as Formik, keep every keystroke in React state by default, so the form re-renders as the user types. React Hook Form leaves the inputs uncontrolled. The browser holds the value and the library reads it when needed, so typing in one field doesn't redraw the others. On a long checkout this is the difference between a form that keeps up and one that stutters. It shows up in INP (Interaction to Next Paint), the Core Web Vital that Google has used since 2024 to measure how quickly a page responds to input.

The moment of validation is a single option:

checkout-form.tsx
const form = useForm({
  resolver: zodResolver(checkoutSchema),
  // First check when the user leaves a field, then live while they fix it
  mode: 'onTouched',
})

The library also moves focus to the first invalid field when a submit fails, and it keeps every value the user entered.

Zod: one set of rules, everywhere

Zod is a library for describing what valid data looks like, such as an email address, a phone number or a quantity, once, in code. It addresses the second and fourth causes, and a problem sales usually notices first: the quality of the data itself.

For the business: error messages are written once, in plain language, and read the same everywhere. What the browser accepts, the server accepts. And the data that reaches your systems is clean: a phone number sales can call, an email address that receives the order confirmation, an address the courier can find.

Under the hood: a Zod schema describes the rules and, just as importantly, cleans the input on the way in:

checkout-schema.ts
import { z } from 'zod'

export const checkoutSchema = z.object({
  email: z
    .string()
    .trim()
    .toLowerCase()
    .pipe(z.email({ error: 'Enter an email like [email protected]' })),
  phone: z
    .string()
    .transform((value) => value.replace(/[\s()-]/g, '').replace(/^0/, '+359'))
    .pipe(
      z.string().regex(/^\+\d{10,14}$/, {
        error: 'Enter a mobile number, e.g. 088 123 4567',
      }),
    ),
  quantity: z.coerce.number().int().min(1, { error: 'Choose at least one item' }),
})

// What the form holds while the customer types, and what the server receives
export type CheckoutInput = z.input<typeof checkoutSchema>
export type Checkout = z.output<typeof checkoutSchema>

The email is trimmed and lower-cased before it is checked, so [email protected] typed with a trailing space is accepted and stored consistently. The phone number is stored in the same international format whether the customer typed 0888 123 456, +359 888 123 456 or (0888) 123-456. That is the format a CRM can dial and an SMS gateway expects. And every message tells people what to type, not merely that they got it wrong.

The same schema runs on the server. In a Next.js application, the server action that receives the order validates with exactly the same rules:

actions.ts
'use server'

import { z } from 'zod'

import { checkoutSchema, type CheckoutInput } from './checkout-schema'
import { createOrder } from './orders'

type PlaceOrderResult =
  | { ok: true; orderId: string }
  | { ok: false; errors: Partial<Record<keyof CheckoutInput, string[]>> }

export async function placeOrder(input: unknown): Promise<PlaceOrderResult> {
  const result = checkoutSchema.safeParse(input)

  if (!result.success) {
    return { ok: false, errors: z.flattenError(result.error).fieldErrors }
  }

  // result.data is typed and clean: trimmed email, normalised phone, a number
  const orderId = await createOrder(result.data)

  return { ok: true, orderId }
}

There is no second copy of the rules to drift out of sync, so "accepted in the browser, rejected by the server" stops happening.

The types come from the same place. z.input describes what the form holds while the customer is typing, and z.output the clean data the server receives. There is no separate interface to keep in sync with the rules, and a renamed field is a compile error rather than a bug report.

Zod isn't the only option:

  • Valibot follows the same idea with a smaller download for the browser.
  • Yup is an older library, still common in Formik projects.
  • ArkType focuses on speed.

Most of them now implement a shared interface, Standard Schema, so the form library and the validation library can be chosen independently. Among form libraries, TanStack Form and Conform are worth a look. Conform is built around forms that work even before JavaScript has loaded. The principles in this article apply to all of them.

Fewer fields or better leads?

Marketing's instinct is right: every field is another reason to stop. Expedia's "Company" field wasn't even required, and it still cost money. Sales' instinct is right too: a lead with nothing but an email address can take several calls to qualify.

The conflict is rarely settled in a meeting. It is settled in how the form is built:

  • Conditional fields. Ask for a company name only when someone chooses "business", or for a delivery address only when it differs from the billing address. Most people see a short form, and those who need the extra fields get them.
  • Ask in steps. Ask for an email address now, and for role, budget or timeline on the next visit or in the first reply. This is often called progressive profiling.
  • Look it up instead of asking. Get the company from the email domain, the city from the postcode, and the registered company details from an EU VAT number through the European Commission's VIES service.
  • Make fields optional, then watch them. Field-level tracking shows which optional fields people actually fill in, and which ones only add friction.

Conditional fields are where the two libraries work together. React Hook Form shows the extra field only when it applies, and a Zod discriminated union validates exactly the fields that are on screen:

lead-form.tsx
const leadSchema = z.discriminatedUnion('customerType', [
  z.object({ customerType: z.literal('personal'), email: z.email() }),
  z.object({
    customerType: z.literal('business'),
    email: z.email(),
    company: z.string().trim().min(2, { error: 'Enter your company name' }),
  }),
])

export function LeadForm({ onSubmit }: { onSubmit: (lead: z.output<typeof leadSchema>) => void }) {
  const form = useForm({ resolver: zodResolver(leadSchema) })
  const customerType = form.watch('customerType')

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {/* customer type and email fields */}
      {customerType === 'business' && (
        <input autoComplete="organization" {...form.register('company')} />
      )}
    </form>
  )
}

Personal customers never see the company field, and business leads never arrive without one.

What no library does for you

Some of the biggest gains come from plain HTML that every modern browser supports:

  • autocomplete attributes such as email, tel, street-address and cc-number let the browser and password managers fill a form in one tap. On mobile it is often the single largest time saver, and it costs one attribute per field.
  • The right keyboard on a phone. type="email" adds the @ key and type="tel" opens the phone keypad. For postcodes, card numbers and one-time codes, inputmode="numeric" shows a digits-only keyboard. type="number" is meant for quantities. It adds spinner arrows and treats the value as an amount, which is wrong for identifiers like these. For codes sent by SMS, autocomplete="one-time-code" lets the phone offer the code straight from the message.
  • Visible labels, not placeholders that disappear as soon as someone starts typing. A clear label is also the cheapest fix for a field like Expedia's, where people guessed what "Company" meant and guessed wrong.
  • Accessible errors. aria-invalid and aria-describedby let screen readers announce what is wrong. Clear, well-placed errors help every user, not only those with assistive technology.
  • Fewer fields. The field you remove never needs validating. Every field should earn its place by being used after the form is submitted.

Forms after the sale

For subscription businesses, and for any shop that keeps customers' cards on file, two more forms appear after the first purchase. Both affect churn, the share of customers who leave.

Updating a payment method. Cards expire and payments fail. The customer receives an email, taps the link and lands on a form. If that form asks them to log in again, retype their address and fight a card field on a small screen, a customer who never meant to leave is lost. They are lost to friction, not to a decision. What helps:

  • The link opens the form directly and securely.
  • The form asks for the card and nothing else.
  • autocomplete="cc-number", cc-exp and cc-csc let the phone fill it from a saved card. Payment providers' hosted card forms handle much of this out of the box.
  • A clear confirmation, such as "Your card is updated, nothing else to do", closes the loop.

Cancelling. It is tempting to hide the cancel button, and it backfires. Customers who can't cancel tend to dispute the charge with their bank instead, which costs fees and trust. In some countries it isn't allowed at all: Germany, for example, requires online contracts to offer a clearly labelled cancellation button. A cancel form that takes a minute, asks one question ("Why are you leaving?") and perhaps offers a pause or a smaller plan does more for retention than any obstacle. The answers to that one question are some of the most useful product feedback a company gets.

Measure it, field by field

Expedia found its $12 million field because someone looked at the failures one field at a time. Megibow said at the time that the team had found 50 or 60 problems of this kind by paying attention to analytics and to customers. None of this is worth much without knowing which field is the problem.

With React Hook Form, field-level tracking takes a few lines. The second argument to handleSubmit receives the errors whenever a submit fails:

checkout-form.tsx
import type { FieldErrors } from 'react-hook-form'

function onInvalid(errors: FieldErrors<CheckoutInput>) {
  for (const field of Object.keys(errors)) {
    track('checkout_field_error', { field })
  }
}

// In the component
<form onSubmit={form.handleSubmit(onValid, onInvalid)}>

Here track stands for whichever analytics tool you already use: Google Analytics, Vercel Analytics, PostHog or any other with custom events. After a few weeks, each team gets an answer it didn't have before:

  • Marketing sees which field costs completions.
  • Sales can match form changes to the share of leads it actually reaches.
  • Engineering sees where validation is too strict or unclear.
  • Retention sees, with the same approach on the payment-update and cancel forms, where customers are lost after the sale.

Double submissions, the quiet cost

One more problem sits between the form and the back office. A customer on a slow connection presses "Place order", nothing seems to happen, and they press it again. The result is two orders, possibly two charges, one unhappy customer and a refund for the support team. React Hook Form knows when a submission is in progress:

checkout-form.tsx
<button type="submit" disabled={form.formState.isSubmitting}>
  {form.formState.isSubmitting ? 'Placing order…' : 'Place order'}
</button>

A disabled button with a clear label is the first line of defence. The server should still recognise a repeated order as the same order, but the fewer duplicates reach it, the better.

The form is a business asset

  • For marketing, the form is where paid-for traffic becomes revenue, or doesn't.
  • For sales, it is the first and cheapest chance to get clean, usable leads.
  • For retention, it is often the difference between a failed payment and a lost customer.
  • For engineering, it is one of the few places where a few well-chosen tools and some attention to detail show up directly in the company's numbers.

Expedia's "Company" field passed every test anyone thought to write. It cost $12 million a year all the same, because nobody had asked how real people read it. The tools for building forms that feel effortless are free, mature and widely used. What separates a good form from a costly one is rarely the technology. It is deciding that the form deserves the same attention as the campaign that brought the customer to it, and measuring it field by field.

Source for the Expedia figures: Silicon.com, via Usability Counts, November 2010.