Why forms lose customers: conversion, leads and the code behind them

Updated 16 min read

Why customers abandon forms, what marketing, sales and engineering each measure, and how Zod, React Hook Form and plain HTML get more of them finished.

Close-up of an empty web form on a screen, with First Name, Last Name and Your Email fields and the cursor in the first one

Most forms don't lose customers to a bug. They lose them in ways that pass every test: an error that appears while someone is still typing, a message that only says "Invalid input", a button that seems to do nothing. Each one is small, and each one shows up only in the numbers, as people who started the form and never finished it, which marketers call form abandonment.

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 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. It covers how two open-source libraries, Zod and React Hook Form, let engineering give each team what it needs, and the forms that decide whether customers stay after the first sale.

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. A form loses orders one field at a time, and only measuring it field by field shows which field is responsible.

What makes people give up

The largest body of evidence on why people give up comes from the Baymard Institute, which has benchmarked the checkouts of large online shops such as Amazon, Walmart and ASOS since 2012. Its 2025 figures: across 50 studies, an average of 70% of shopping carts are abandoned, and 17% of US online shoppers have abandoned an order because the checkout was too long or complicated. The average US checkout shows 23.48 form elements by default, where 12 to 14 would be enough. Baymard estimates that better checkout design alone could raise conversion on a large online shop by about 35%. Across US and EU e-commerce, that adds up to some $260 billion in recoverable orders.

Behind numbers like these are a handful of well-known causes:

  • 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.
  • Not finding the error. On a phone, the button is at the bottom and the problem is several screens up. The customer taps it, nothing seems to happen, and the form looks broken.
  • 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, one of the most widely used libraries for building web interfaces. It addresses the causes marketing sees as people who start a form and never finish it: errors that arrive too early, errors nobody can find, lost input and lag.

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 a submit fails, the form takes them to the first problem. 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 March 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',
})

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 vague errors and the gap between browser and server. It also addresses 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 name@example.com' })),
  phone: z
    .string()
    // Bulgarian numbers: 0888 123 456 becomes +359888123456
    .transform((value) => value.replace(/[\s()-]/g, '').replace(/^0/, '+359'))
    .pipe(
      z.string().regex(/^\+\d{10,14}$/, {
        error: 'Enter a mobile number, e.g. 0888 123 456',
      }),
    ),
  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 Name@Example.com 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. With the noValidate attribute on the form, the browser's own pop-up messages stay out of the way, so every error comes from the schema, worded and styled the same way.

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. Baymard found that the average checkout in 2024 asked for 11.3 form fields, while most shops need only 8. 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 the platform itself: standard HTML attributes that every modern browser supports, and the accessibility semantics defined by WAI-ARIA. Most of them also map to success criteria in the Web Content Accessibility Guidelines (WCAG) 2.2, the standard that accessibility laws usually point to. In the EU, the European Accessibility Act has applied to most online shops since June 2025.

  • Autofill. The autocomplete attribute tells the browser and password managers what each field is for. Tokens such as email, tel, street-address and cc-number let them fill a form in one tap. On mobile it is often one of the largest time savers, and it costs one attribute per field. For fields that collect information about the user, it is also a WCAG requirement: 1.3.5 Identify Input Purpose.
  • The right keyboard. The input type and the inputmode attribute decide which keyboard a phone shows. 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. A <label> element tied to its field stays visible while people type, unlike a placeholder, and gives the field its accessible name, the text a screen reader announces. A clear label is also the cheapest fix for a field people misread: when the label leaves room to guess, some people guess wrong. WCAG covers it in 3.3.2 Labels or Instructions.
  • Accessible errors. Two ARIA attributes connect a field to its error. aria-invalid marks the field as failing validation, and aria-describedby points to the message, so a screen reader reads it out when the field receives focus. WCAG asks for errors that are identified in text, in 3.3.1 Error Identification, and that suggest a fix, in 3.3.3 Error Suggestion. Clear, well-placed errors help every user, not only those with assistive technology.

The small things that get a form finished

The best forms are full of details nobody notices. Their only job is to get the form sent without a second thought, and most of them take a line or two of code:

  • Taking people to the error. When a submit fails, React Hook Form moves focus to the first invalid field and the browser scrolls to it, which is what Baymard recommends for a single error. Two gaps remain. Safari on iPhones and iPads doesn't move focus to radio buttons, checkboxes or drop-downs, so when one of those is first, the page doesn't move. I saw this on the contact form on this site: with a row of topic buttons as the first field, a failed submit on an iPhone didn't move the page at all. And the browser scrolls only as far as the input, which can leave its label under a sticky header. Scrolling the whole field into view with scrollIntoView and then focusing it closes both. For long forms with several errors, a summary at the top that links to each field works better.
  • Server errors in the same place. If the server rejects something the browser accepted, setError puts the message on the same field, in the same words, and moves focus to it.
  • Saying which fields are optional. A small "Optional" next to the label spares people from guessing what they can skip.
  • A failure with a way out. If sending fails, everything typed stays in place, and the message offers another route, such as an email address.
  • A confirmation that answers the next question. Not just "Thank you", but where the reply will go and when. Repeating the email address back also lets people catch a typo.

None of these shows up in a feature list. Each one removes a moment where someone could hesitate.

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", the page doesn't respond, 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, but only in the browser. A request can still be repeated by a network retry, a second tab or an impatient refresh, so the server has to recognise a repeated order as the same order. What makes that possible is idempotency, the property that performing an operation several times has the same effect as performing it once. Payment APIs such as Stripe's build it in with an idempotency key, a unique value sent with each request so that a retry is recognised rather than processed twice, and the IETF is standardising the approach as the Idempotency-Key HTTP header. The button keeps most duplicates away, and idempotency makes the rest harmless. But that is a backend story, and one for another article.

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 to friction rather than to a decision. That kind of churn is among the easiest to prevent, and among the easiest to miss. 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. Some customers who can't cancel 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 consumer contracts concluded online, such as subscriptions, 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

Benchmarks like Baymard's show how much a typical checkout loses. They can't show which field on your form is losing it, and none of the fixes above is worth much without knowing that.

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, every team has an answer it didn't have before: which field costs completions, how form changes affect the leads sales can reach, where validation is too strict or unclear, and, with the same events on the payment and cancel forms, where customers are lost after the sale.

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 well-chosen tools and attention to detail show up directly in the company's numbers.

The contact form on this site is built this way, small things included. One Zod schema holds the rules and the error messages, React Hook Form checks each field when it is left and takes you to the first problem when a submit fails, and the server action that sends the message validates the same schema again. The schema also carries two spam traps: a hidden field that only bots fill in, and the time the form was opened, so a message sent faster than a person can type is quietly dropped.

A form can pass every test anyone thought to write and still lose orders, because nobody checked how real people read it and fill it in. 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. If your form gets visits but few submissions, and nobody can say which field is to blame, tell me what you're working on.

Sources: Baymard Institute, Cart abandonment rate statistics (updated September 2025), Checkout optimization: minimize form fields (June 2024) and Checkout UX guide (April 2026); GOV.UK Design System, Error summary; React Hook Form, Not focusing checkboxes, radios, and selects on iOS.