I11 · Part 2 · Intermediate
Forms at scale
Where hand-rolled state stops paying, and what a form library actually buys you.
09On this page
The point where it stops working
A login form needs no library. The trouble starts at a specific set of requirements, and it is worth naming them so you can tell which one you have:
- Twenty fields, so every keystroke re-renders all twenty.
- Repeated groups — add a line item, remove one, reorder.
- Rules that span fields: end date after start date, total must match.
- Async validation, like checking a username is free, debounced.
- Multi-step wizards with per-step validation and a shared draft.
- Server errors that need mapping back onto individual fields.
React Hook Form, and why it is fast
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm();
<form onSubmit={handleSubmit(onValid)}>
<input {...register("email", { required: "Email is required" })} />
{errors.email && <p>{errors.email.message}</p>}
<button disabled={isSubmitting}>Save</button>
</form>
register returns name, onChange,
onBlur and a ref. The inputs are
uncontrolled — values live in the DOM and the library reads them
through refs, so typing does not re-render the form at all. Only the
components that subscribe to a specific error re-render.
That is the whole performance story, and it is why the library exists. Controlled forms re-render on every keystroke; at twenty fields with any derived work, that becomes visible.
Schema validation
const schema = z.object({
email: z.string().email("Not a valid email"),
age: z.coerce.number().min(18, "Must be 18 or over"),
password: z.string().min(8),
confirm: z.string(),
}).refine((d) => d.password === d.confirm, {
message: "Passwords do not match",
path: ["confirm"], // attach the error to a field
});
const { register, handleSubmit } = useForm({ resolver: zodResolver(schema) });
The rules move out of the JSX into one object, which means they can be reused — the same schema validates on the client, on the server, and produces the TypeScript type:
type FormValues = z.infer<typeof schema>; // one source of truth
Client validation is UX, not security
Anything the browser checks can be bypassed with one curl. The
server must validate the same rules independently — sharing the schema
is what makes that cheap rather than a second implementation that drifts.
Dynamic field arrays
const { fields, append, remove } = useFieldArray({ control, name: "items" });
{fields.map((field, index) => (
<div key={field.id}> // field.id, never index
<input {...register(`items.${index}.name`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ name: "" })}>Add</button>
This is the index-key bug in its most
expensive form: remove the first row with index keys and every row below keeps
the previous row's typed value. The library generates a stable
field.id precisely for this.
Mapping server errors back onto fields
try {
await save(values);
} catch (err) {
if (err.fields) {
for (const [name, message] of Object.entries(err.fields)) {
setError(name, { message }); // lands on the right input
}
} else {
setError("root", { message: err.message }); // form-level
}
}
"Email already registered" belongs next to the email field, not in a banner at the top. This is the step most hand-rolled forms skip, and it is the difference between a form people can fix and one they abandon.
Accessibility, which is most of the remaining work
<label htmlFor="email">Email</label>
<input
id="email"
{...register("email")}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
autoComplete="email"
/>
{errors.email && <p id="email-error" role="alert">{errors.email.message}</p>}
- Every input has a real label. A placeholder is not one.
aria-describedbyties the error to the field, so it is announced with it.- Move focus to the first error on submit — otherwise a keyboard user has no idea what failed or where.
autoCompleteis the difference between a form the browser fills and one it does not.
Multi-step forms
Keep one state object for the whole wizard, held above the steps, and validate per step against a slice of the schema. Put the step number in the URL so refresh and back work, and persist the draft — a wizard that loses everything on an accidental refresh is the most annoying form there is.
React 19 form actions
function Signup() {
const [state, formAction, isPending] = useActionState(signup, null);
return (
<form action={formAction}>
<input name="email" />
<button disabled={isPending}>Sign up</button>
{state?.error && <p>{state.error}</p>}
</form>
);
}
A <form action={fn}> gets the pending state, the result and
progressive enhancement built in — and it works before hydration.
useFormStatus lets a nested submit button read the pending state
without prop drilling. For simple forms this removes the library entirely;
Server Actions takes it further.
Choosing
| Form | Reach for |
|---|---|
| Login, search, a few fields | useState, or a form action |
| 10+ fields, or cross-field rules | React Hook Form + Zod |
| Dynamic rows, wizards, drafts | React Hook Form, definitely |
| Framework with server actions | Actions first; add a library only for the client-side niceties |
Interview answer, one sentence
"Hand-rolled controlled forms re-render everything on every keystroke and make you reimplement validation, arrays and error mapping. React Hook Form keeps values in the DOM and subscribes narrowly, and a Zod schema gives you client validation, server validation and the TypeScript type from one definition."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series