Forms and validation in React
For non-trivial forms, use `react-hook-form` + `zod`. RHF uses uncontrolled inputs under the hood (no re-render per keystroke), Zod owns the schema (single source of truth for types + runtime validation). Validate on blur for individual fields and on submit for the whole form. Accessibility: associate errors to fields with `aria-describedby`; focus the first invalid field on submit failure. Always re-validate on the server.
Forms in React look simple. At scale they're where 80% of the bugs hide — controlled-vs-uncontrolled choices, validation timing, accessibility, error UX, server errors.
The default stack in 2026
react-hook-form → form state (uncontrolled, performant)
zod → schema (single source of truth)
@hookform/resolvers/zod → glueimport { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const Schema = z.object({
email: z.string().email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
});
type FormValues = z.infer<typeof Schema>;
function SignupForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } =
useForm<FormValues>({ resolver: zodResolver(Schema), mode: "onBlur" });
async function onSubmit(values: FormValues) {
try {
await api.signup(values);
} catch (e) {
if (e.code === "EMAIL_TAKEN") {
setError("email", { message: "Already registered" });
} else {
setError("root", { message: "Something went wrong" });
}
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label>
Email
<input
{...register("email")}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-err" : undefined}
/>
{errors.email && <p id="email-err" role="alert">{errors.email.message}</p>}
</label>
<button disabled={isSubmitting}>Submit</button>
</form>
);
}Why this stack.
- Zod owns types AND validation. No drift between TypeScript and runtime checks.
- RHF uses uncontrolled inputs. No re-render per keystroke. 200-field forms stay fast.
isSubmitting,errors,isDirty,isValid— comprehensive form state with no plumbing.- Server-side: the same Zod schema validates the request body. Single source of truth.
Validation timing
| Trigger | When |
|---|---|
onSubmit | Default. Don't error before they try. |
onBlur | Per-field error after they leave. Best balance. |
onChange | After first attempt — show progress as they fix. |
onTouched | RHF: onBlur first time, then onChange. |
Modern UX: mode: "onBlur" for first-pass; switch to "onChange" after isSubmitted. RHF supports this via reValidateMode.
Server errors
The form must accept errors the server returns:
catch (e) {
if (e.fieldErrors) {
for (const [field, msg] of Object.entries(e.fieldErrors)) {
setError(field as keyof FormValues, { message: msg });
}
} else {
setError("root", { message: e.message });
}
}Server response shape (typical):
{
"fieldErrors": { "email": "already in use" },
"rootError": null
}Display field errors next to the field; root errors at the top of the form.
Accessibility
The four things that matter:
- Labels. Every input has a programmatic label —
<label>wrapping orhtmlFor. - Errors associated with inputs.
aria-describedbypoints to the error message. aria-invalidflags invalid fields.- Focus the first invalid field on submit failure.
``tsx const { handleSubmit, setFocus } = useForm({ shouldFocusError: true }); ` RHF does this automatically when shouldFocusError` is enabled.
Plus: don't disable the submit button while invalid (it stops keyboard users from triggering the validation feedback). Let them submit and show errors.
Complex patterns
Conditional fields. Use watch() to read other fields, render conditionally; or use the useWatch hook for performance.
Field arrays (useFieldArray) — dynamic list of inputs (tags, attendees). Each row needs a stable key, never index, or removal breaks state.
Multi-step wizards. One useForm per wizard (single source of truth), step components consume slices via Controller or watch.
File uploads. Uncontrolled by nature. RHF stores FileList; convert via onChange. Validate size/type with Zod (z.instanceof(File).refine(...)).
Async validation (username available?). Debounce in the validator; throw on conflict. RHF supports async resolvers.
Drafts / autosave. Watch the form values, debounce, persist to localStorage or to the server. On mount, restore.
Anti-patterns
- Manual
useStateper field. Tedious; re-renders on every keystroke; no validation framework. - Validation logic inline in the component. Move to Zod; reuse on server.
- Disabling submit until valid. Keyboard users get stuck without knowing why.
- Error messages only at the top. Field-level + summary, both.
- Trusting client validation for security. Always revalidate server-side.
Senior framing
The interviewer wants:
- Library choice with rationale (RHF + Zod, not 10
useStates). - Single schema for client + server validation.
- Validation timing thought through (onBlur default).
- Accessibility (labels, errors, focus management).
- Server-side error integration.
- Performance posture (RHF's uncontrolled approach for large forms).
- Acknowledgment that the server is the security boundary, not the form.
The "I use useState and validate in onSubmit" answer is junior. The stack + considerations above is senior.
Follow-up questions
- •Why is RHF's uncontrolled approach faster than controlled inputs?
- •How would you share validation between client and server?
- •How do you map server-side fieldErrors back to form fields?
- •Why focus the first invalid field instead of just showing errors?
Common mistakes
- •Re-implementing validation logic in two places.
- •Disabling submit until valid (a11y trap).
- •Index-based keys in `useFieldArray`.
- •No `aria-describedby` linking input to its error.
Performance considerations
- •RHF avoids per-keystroke parent re-renders.
- •Large forms — split into sub-components with `Controller` for targeted re-renders.
- •Async validators — debounce.
Edge cases
- •Browser autofill doesn't always fire React's onChange — RHF subscribes via uncontrolled refs.
- •IME composition — wait for compositionEnd before validating.
- •File inputs can't be controlled — RHF handles via uncontrolled ref.
Real-world examples
- •react-hook-form + zod is the modal choice in OSS React codebases in 2026.
- •Formik is legacy at this point but still in many older projects.