Skip to content
intermediate Phase 12 · Client State and Libraries

Complex Forms with react-hook-form

Manage large forms with validation and field arrays.

50m
0 problems
Topic Progress 0%

Twenty Fields, Zero Keystroke Rerenders: How react-hook-form Tracks Values

An onboarding form has twenty fields. The naive client component gives every input its own useState, so typing one character rerenders the form and its entire subtree — twenty controlled inputs, twenty state slices, one busy main thread. react-hook-form (RHF) takes the opposite route: inputs are uncontrolled by default — React does not hold their values. The library registers each input through a ref, keeps the values in a ref-backed registry, and reads the DOM on demand at validation and submit time. Keystrokes never enter React's render cycle at all.

'use client';
import { useForm } from 'react-hook-form';

type Values = {
  email: string;
  company: string;
  seats: number;
};

export function OnboardingForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<Values>({
    defaultValues: { email: '', company: '', seats: 1 },
    mode: 'onBlur',
  });

  const onSubmit = handleSubmit(async (values) => {
    await fetch('/api/onboard', {
      method: 'POST',
      body: JSON.stringify(values),
    });
  });

  return (
    <form onSubmit={onSubmit} noValidate>
      <input
        {...register('email', {
          required: 'Email is required',
          pattern: { value: /\S+@\S+\.\S+/, message: 'Enter a valid email' },
        })}
      />
      {errors.email && <p role='alert'>{errors.email.message}</p>}

      <input {...register('company')} />

      <button type='submit' disabled={isSubmitting}>
        Create account
      </button>
    </form>
  );
}

register('email', rules) returns a props object — name, onChange, onBlur, ref — which you spread onto the input. The ref callback is how the library builds its registry of field nodes; that registry is the state. handleSubmit wraps your submit handler: it runs validation first and only calls your function with parsed values when everything passes.

So what does rerender? Only what formState subscribed you to. The line const { errors, isSubmitting } = formState destructures from a Proxy-backed object — React registers a subscription for exactly the properties you read, and the component rerenders when those change: an error message appears, then clears once fixed; isSubmitting toggles around the async submit. Nothing else moves.

The trade-off is worth seeing directly:

Concern useState per input react-hook-form
Rerender per keystroke Whole form subtree None
Values live in React state Ref registry, read from the DOM
Cursor / IME surprises Possible when async results feed value back None — the library never writes the input
Truly controlled widgets The default <Controller> bridges them in

Two details in the snippet carry weight. noValidate on the form element disables the browser's own validation bubbles, so your messages are the only ones the user sees instead of a second native tooltip fighting them. And mode: 'onBlur' chooses when validation runs — the default 'onSubmit' stays quiet until the first submit attempt, 'onBlur' surfaces each error as you leave the field, 'onChange' validates continuously. For long forms, 'onBlur' (RHF also ships an 'onTouched' hybrid) is the common choice: early feedback without keystroke noise.

Not every input can be uncontrolled. An autocomplete with async options, an input mask, anything whose value is genuinely driven by external state — those need <Controller>, which registers a controlled widget with RHF so validation and error display keep working:

<Controller
  control={control}
  name='country'
  render={({ field }) => (
    <CountryCombobox value={field.value} onChange={field.onChange} />
  )}
/>

Use it for those cases only; every <Controller> reintroduces exactly the rerender behavior RHF removed. And if the form looks simple now, remember where state machinery in this phase ends up: the moment a schema must also guard the server, inline rules start to drift — the next chapter replaces them with one shared definition.

One Zod Schema, Two Enforcement Points: Client Resolver and Server Action

Inline rules in register validate the browser's copy of the truth. The server still has its own rules — a Server Action, a route handler, a second hand-written check — and the two lists drift the day someone adds a constraint to one side. The fix is a single Zod schema imported by both, which also gives you an inferred TypeScript type for free.

// lib/schemas/onboarding.ts
import { z } from 'zod';

export const onboardingSchema = z.object({
  email: z.string().email('Enter a valid email'),
  company: z.string().min(2, 'Company name is too short'),
  seats: z.coerce.number().int('Seats must be a whole number').min(1).max(500),
});

export type OnboardingValues = z.infer<typeof onboardingSchema>;

On the client, zodResolver plugs that schema into RHF, replacing the inline rules:

'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { onboardingSchema, type OnboardingValues } from '@/lib/schemas/onboarding';

export function OnboardingForm() {
  const { register, handleSubmit, formState: { errors } } =
    useForm<OnboardingValues>({
      resolver: zodResolver(onboardingSchema),
      defaultValues: { email: '', company: '', seats: 1 },
      mode: 'onBlur',
    });

  const onSubmit = handleSubmit(async (values) => {
    const result = await createAccount(values); // server action
    if (!result.ok) showError(result.fieldErrors);
  });
  // ...
}

On the server, the action re-parses with the same schema, because client validation is UX, not security: anyone can submit without JavaScript or with edited JavaScript. A server action receives the values as ordinary serializable arguments and returns serializable results:

// app/actions/onboard.ts
'use server';

import { onboardingSchema, type OnboardingValues } from '@/lib/schemas/onboarding';
import { db } from '@/lib/db';

export async function createAccount(values: OnboardingValues) {
  const parsed = onboardingSchema.safeParse(values);

  if (!parsed.success) {
    return {
      ok: false as const,
      fieldErrors: parsed.error.flatten().fieldErrors,
    };
  }

  await db.user.create({ data: parsed.data });
  return { ok: true as const };
}

safeParse never throws; it returns a discriminated result you can branch on. .flatten().fieldErrors produces exactly the shape the form wants — a map of field name to message array — so server-side failures render next to the same fields the resolver governs. The as const return types keep result.ok a literal discriminant for TypeScript.

One line in the schema deserves its own paragraph: z.coerce.number(). Every value read from an <input> or FormData arrives as a string — seats is "5", not 5 — and a plain z.number() would fail the server parse on every single submit, usually discovered only in production. Putting coercion in the schema makes the conversion one shared definition instead of scattered Number(...) calls in handlers and actions.

Why enforce on both sides even though the resolver already validated? Three reasons in order of nastiness: the request can bypass the page entirely (curl, a stale cached bundle), the schema can change after a client loaded the old rules, and the database deserves a typed, parsed value regardless of which client sent it. The resolver's job is fast feedback; the action's safeParse is the actual gate — the same pattern as any client-side permission check that must be re-verified on the server.

Wiring the returned errors into the UI is plain React: keep the last result in state and render server messages beside the resolver's, or use useActionState if the form posts through a plain <form action={...}>. One more symmetry: the same schema also guards a no-JavaScript fallback, because safeParse accepts whatever shape you hand it — a FormData mapped field by field, the values object from the resolver, or a payload from a script that never loaded your bundle. Either way the user sees one error per field from one schema definition — client and server can no longer disagree about what a valid email is.

Field Arrays, Vanishing Values, and Validation That Fires at the Wrong Time

Repeating rows: useFieldArray and the key that must not be an index

An order form needs arbitrary line items. useFieldArray owns the add/remove lifecycle:

'use client';
import { useFieldArray, useForm } from 'react-hook-form';

type LineItem = { sku: string; qty: number };
type OrderForm = { items: LineItem[] };

export function OrderLinesForm() {
  const { register, control, handleSubmit } = useForm<OrderForm>({
    defaultValues: { items: [{ sku: '', qty: 1 }] },
  });
  const { fields, append, remove } = useFieldArray({
    control,
    name: 'items',
  });

  return (
    <form onSubmit={handleSubmit(save)}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`items.${index}.sku`)} placeholder='SKU' />
          <input type='number' {...register(`items.${index}.qty`)} />
          <button type='button' onClick={() => remove(index)}>
            Remove
          </button>
        </div>
      ))}
      <button type='button' onClick={() => append({ sku: '', qty: 1 })}>
        Add line
      </button>
      <button type='submit'>Save order</button>
    </form>
  );
}

field.id is a stable, generated identifier the library assigns per row — use it as the React key, never index. With index keys, removing the middle of four rows shifts every later row's data up by one position while React reuses the DOM nodes by position: the input you were focused in now displays a different row's values, and focus lands on the wrong line. The generated id exists precisely so React can tell rows apart through insertions and removals. Array validation belongs in the same shared schema — items: z.array(z.object({ sku: z.string().min(1), qty: z.coerce.number().min(1) })).min(1) — and errors surface per row at errors.items?.[index]?.sku.

The defaults that vanish on an async edit form

Because inputs are uncontrolled, the value prop is not the mechanism — pass it and the input keeps whatever the DOM holds, often empty. An edit form that fetches its data client-side renders blank fields no matter what props it receives:

// wrong: value={data.name} on a registered input does nothing after mount
useEffect(() => {
  if (data) reset(data); // right: push fetched values into the registry
}, [data, reset]);

reset(values) rewrites the registry and the DOM together. Prefer server-rendered defaults — fetch in the Server Component and pass down — and reserve reset for data that genuinely arrives late.

Validation timing, chosen deliberately

The default mode: 'onSubmit' means a form full of errors stays silent until the user commits, which feels broken on twenty fields. 'onBlur' gives per-field feedback the moment a field is left; 'onChange' validates every keystroke (noisy, but right for search-as-you-type filters). reValidateMode (default 'onChange') governs the second phase: after a failed submit, already-errored fields re-validate live so the error clears the instant the user fixes it. Pick the mode as a UX decision, not by accident of the default.

The controlled-widget escape hatch, used sparingly

When a widget is genuinely controlled — an autocomplete fetching options, a formatted mask — <Controller> bridges it into RHF's registry. Resist the alternative of useState per field "just for this form": it quietly reinstalls the per-keystroke rerenders this library was chosen to remove, and validation now lives in two systems.

One structural note before moving on: the reducer from Local State with useState and useReducer, the store selector from Global Client State with Zustand, and this chapter's Zod schema are all pure functions with typed inputs and outputs — the exact units the next topic, Unit Testing Utilities, puts under test first, no DOM required. A regression in any of them surfaces there as a failing unit test long before it surfaces as a support ticket.

Quiz

1. Why does a react-hook-form form avoid rerendering on every keystroke?

Question 1 options

2. The client resolver validated the form, but a request bypasses the page and posts invalid data to the Server Action. What is the correct response?

Question 2 options

3. A field array maps rows with key={index}. A user removes the second of four rows while typing in the fourth. What goes wrong?

Question 3 options

4. An edit form fetches the record client-side and passes data.name to a registered input's value prop. The input renders empty. What is the fix?

Question 4 options

Flashcards

Question

How does react-hook-form store field values, and why does that avoid per-keystroke rerenders?

Answer

Registered inputs are uncontrolled: register() attaches a ref and native listeners, and values live in a ref-backed registry read from the DOM at validation/submit time. Keystrokes never become React state, so nothing renders; only formState subscriptions (errors, isSubmitting) rerender the component.

Question

What triggers rerenders after the initial mount in a react-hook-form component?

Answer

Only changes to the formState properties you destructured — formState is a Proxy that subscribes you to exactly the fields you read (errors.email, isSubmitting). Validation events update the registry silently; the component rerenders when a subscribed error or flag flips.

Question

What are the two enforcement points for a Zod schema in a form flow, and what does each do?

Answer

The client resolver (zodResolver in useForm) gives fast, per-field feedback as UX. The Server Action's safeParse is the actual gate — it re-validates untrusted input, returns fieldErrors via error.flatten(), and hands the parsed, coerced value to the database. One schema definition feeds both plus the inferred TypeScript type.

Question

Why must a useFieldArray row use field.id as its React key instead of the index?

Answer

field.id is a stable generated identifier per row. Index keys reuse DOM nodes by position, so removing a middle row shifts data under the wrong inputs and leaves focus on the wrong line; stable ids let React track rows through insertions and removals.

Question

Why does value={data.name} do nothing on a registered input, and what is the correct pattern for late-arriving edit data?

Answer

Registered inputs are uncontrolled — the DOM, not React props, holds the value after mount. Fetch in the Server Component and pass defaultValues so the registry is populated at first render; when data genuinely arrives client-side later, call reset(data) to update registry and DOM together.

Revision Notes

Key Takeaways

  • 1. RHF keeps values in a ref registry on uncontrolled inputs, so keystrokes never rerender; only destructured formState properties subscribe the component
  • 2. One Zod schema drives the client resolver, the server action's safeParse, and the inferred TypeScript type — the definition cannot drift from itself
  • 3. Client validation is a courtesy; the Server Action must re-parse because the request can bypass the page entirely
  • 4. useFieldArray rows are keyed by the generated field.id — index keys scramble DOM, values, and focus on removal
  • 5. Late-arriving edit data is pushed with reset(), never by feeding value props to registered inputs

Interview Tips

  • • "Why react-hook-form over controlled useState inputs?" — the storage model: refs and DOM reads mean zero keystroke renders; quantify it (a 20-field form rerenders per keystroke when controlled) and mention Controller as the narrow escape hatch
  • • "Where do you validate?" — both places, with different jobs: resolver for fast UX, safeParse in the action as the enforceable gate returning fieldErrors; mention coercion of FormData strings via the schema
  • • "How do you handle repeating rows?" — useFieldArray with stable field.id keys; explain the concrete symptom of index keys (focus and values shifting on middle-row removal) to prove you have shipped it
  • • "Edit form loads data but fields stay empty" — uncontrolled inputs ignore value props after mount; answer with server-fetched defaultValues or reset(data), and show you know why (the registry, not React, owns the value)

Cheat Sheet

Complex Forms with react-hook-form

Core shape:

'use client';
const { register, handleSubmit, control, reset, formState } =
  useForm<Values>({
    resolver: zodResolver(onboardingSchema), // one schema, both sides
    defaultValues: { email: '', seats: 1 },
    mode: 'onBlur',
  });

<form onSubmit={handleSubmit(onSubmit)} noValidate>
  <input {...register('email')} />
  {errors.email && <p role='alert'>{errors.email.message}</p>}
</form>

Server gate (same schema):

'use server';
const parsed = onboardingSchema.safeParse(values);
if (!parsed.success) return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };

Field arrays: useFieldArray({ control, name: 'items' }) · key each row with field.id (never index) · register nested fields as items.${index}.sku.

Modes: onSubmit (silent until commit) · onBlur (feedback per field) · onChange (live) · reValidateMode: 'onChange' clears fixed errors immediately after a failed submit.

Rules: uncontrolled by default — Controller only for genuinely controlled widgets · schema-level z.coerce.number() because inputs and FormData yield strings · late data goes through reset(data), not value props · client resolver = UX, server safeParse = the gate.

Continue Learning