The form is where the sale happens: Zod, React Hook Form and the metrics that matter

11 min read

Why customers abandon forms, which metrics show it, and how Zod and React Hook Form fix most of the causes - from forms in 2000 to forms today.

The form is where the sale happens

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, and raising the share of people who finish - 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, and 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 - updating a card that has expired, or cancelling a subscription - decide whether they stay. 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.

Technically, a form today has little in common with the forms of 2000, which 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. The tools have changed completely. What hasn't changed is how easily a form can lose a customer.

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.

What makes people give up

The causes are remarkably consistent:

  1. Being told off too early. A field that turns red while you are still typing your email address feels like being interrupted mid-sentence.
  2. Vague errors. "Invalid input" gives no clue what the form wants.
  3. Losing what you typed. Nothing ends a purchase faster than having to fill in a form twice.
  4. Accepted here, rejected there. The browser accepts the input, the server rejects it after submit, and the message makes no sense.
  5. A form that lags on a phone. A long form that can't keep up with your thumbs feels broken.
  6. 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 causes 1, 3 and 5 - 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, not 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, and 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 - an email address, a phone number, a quantity - once, in code. It addresses causes 2 and 4, 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 - 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 - so 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 is not 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, and 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 - the latter built around forms that work even before JavaScript has loaded - are worth a look. 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. 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; those who need the extra fields get them.
  • Ask in steps. An email address now; 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. The company from the email domain, the city from the postcode, 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.
  • Accessible errors. aria-invalid and aria-describedby let screen readers announce what is wrong - and 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 - to friction, not to a decision. The link should open the form directly and securely, the form should ask for the card and nothing else, and autocomplete="cc-number", cc-exp and cc-csc should 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 - "Your card is updated, nothing else to do" - closes the loop.

Cancelling. It is tempting to hide the cancel button. It backfires: customers who can't cancel tend to dispute the charge with their bank instead, which costs fees and trust, and in some countries it isn't allowed - 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

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. And the same approach on the payment-update and cancel forms shows retention 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. 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. And 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.

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.

How a PHP request actually runs

What happens between nginx and your PHP code: PHP-FPM, OPcache and the share-nothing model - and why they explain most PHP performance and scaling problems.