Describe the rules once in a Zod schema and use it twice: in the form through React Hook Form and zodResolver, and on the server through safeParse — browser checks save the user time, but only the server check protects your data. Show an error after the user leaves the field (mode: "onTouched"), link it to the field with aria-invalid and aria-describedby, and disable the button while isSubmitting is true.
After this you will be able to
- Choose between a controlled (
value+onChange) and an uncontrolled (defaultValue+FormData) input and explain the cost of each - Describe form data with a Zod schema and validate both the form and the server request body with it
- Build a React Hook Form form that shows an error after the user leaves a field and blocks double submission
- Link an error to its field with
aria-invalidandaria-describedbyand verify it with a Testing Library test
Verified with
React 19.3React Hook Form 7.90Zod 4.6@hookform/resolvers 5.9TypeScript 6.0Vite 8.3Vitest 5.0 + Testing Library 16.3
Checked against docs: October 11, 2026
Contents
- Controlled and uncontrolled inputs
- Native HTML validation and its limits
- A Zod schema: one source of truth for client and server
- React Hook Form: registering fields and timing errors
- Submitting: pending, server error, success
- Accessible errors: aria-invalid and aria-describedby
- Verification: tests and common mistakes
Controlled and uncontrolled inputs
A form field's value can live in one of two places: in React state or in the DOM element itself. A field with value and onChange is controlled: on every render React forces the value from state into it, and onChange updates that state. A field without value — at most with defaultValue — is uncontrolled: JSX sets the initial value, the browser keeps it from then on, and you read it on submit via FormData or a ref.
The React docs set a few hard rules. Pass value without onChange and the field becomes impossible to type into, with a warning in the console. A controlled value must not be undefined or null — start with an empty string. A field cannot switch from uncontrolled to controlled during its lifetime. And, importantly for performance, a controlled field updates state on every keystroke, so with a large component tree typing starts to lag — move the state into a small component next to the field.
Where the value lives
- Controlled
- React state (
useState) - Uncontrolled
- The DOM element, read on submit
Props
- Controlled
value+onChange(checkedfor checkboxes)- Uncontrolled
defaultValue/defaultChecked, plus a requiredname
Re-renders
- Controlled
- On every keystroke
- Uncontrolled
- Only when something else changes
Use when
- Controlled
- The UI depends on the value right now: a character counter, a dependent field, search as you type
- Uncontrolled
- The value matters only on submit — most fields of a lead form
import { useState } from "react";
const MAX = 200;
/* controlled: the value lives in state because the UI depends on it */
export function CommentField() {
const [comment, setComment] = useState("");
const left = MAX - comment.length;
return (
<div>
<label htmlFor="comment">Comment</label>
<textarea
id="comment"
name="comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
maxLength={MAX}
/>
<p>{left} characters left</p>
</div>
);
}Native HTML validation and its limits
The browser can validate fields without a single line of JavaScript. The required, minlength and pattern attributes and the email and url types define constraints, and the result lives in a ValidityState object (input.validity). While any field fails, the browser will not submit the form: the submit event never fires, React's onSubmit handler is not called, and the first invalid field shows a built-in message.
import type { FormEvent } from "react";
type Props = { onSend: (data: Record<string, FormDataEntryValue>) => void };
/* uncontrolled fields + built-in browser validation */
export function NativeLeadForm({ onSend }: Props) {
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); // the browser calls onSubmit only if every field is valid
onSend(Object.fromEntries(new FormData(e.currentTarget)));
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="native-name">Name</label>
<input id="native-name" name="name" required minLength={2} autoComplete="name" />
<label htmlFor="native-phone">Phone</label>
<input
id="native-phone"
name="phone"
type="tel"
required
pattern="[+0-9 \(\)\-]{10,18}"
autoComplete="tel"
/>
<button type="submit">Send</button>
</form>
);
}FormDataFor a simple form that may be enough. But native validation has limits, which is why apps usually add their own on top:
- The wording and look of built-in messages depend on the browser and cannot be styled — MDN says so explicitly on the
aria-invalidpage. - The browser shows a message for one field at a time, not a list of all errors.
- Some constraints, such as
minlength, are only checked for user input, not for values set from JavaScript. - Attributes cannot normalize data (turn "8 700…" into "+7700…").
- Browser validation is easy to bypass, so MDN stresses that it does not replace server-side validation. And without a shared schema you have to describe the same rules again on the server.
A Zod schema: one source of truth for client and server
A schema describes the shape of data in code: which fields, which types, which constraints and which error messages. Zod is a TypeScript library that uses a schema both to validate a value and to infer a static type from it. parse returns a validated copy of the data or throws a ZodError; safeParse returns a { success, data | error } object instead of throwing. In Zod 4 the error text is set with the error parameter.
import * as z from "zod";
export const SERVICES = ["website", "web-app", "crm"] as const;
/* "8 (700) 123-45-67", "+7 700 123 45 67", "7001234567" → "+77001234567" */
export function normalizePhone(raw: string): string {
const digits = raw.replace(/\D/g, "");
if (digits.length === 10) return `+7${digits}`;
if (digits.length === 11 && /^[78]/.test(digits)) return `+7${digits.slice(1)}`;
return raw.trim();
}
export const leadSchema = z.object({
name: z
.string()
.trim()
.min(2, { error: "Enter your name" })
.max(80, { error: "Name is too long" }),
phone: z
.string()
.transform(normalizePhone)
.pipe(z.string().regex(/^\+7\d{10}$/, { error: "Enter a phone like +7 700 123 45 67" })),
service: z.string().pipe(z.enum(SERVICES, { error: "Choose a service" })),
consent: z.boolean().refine((value) => value, { error: "Consent is required to send the form" }),
});
/* what the form holds (input) and what you get after parsing (output) */
export type LeadInput = z.input<typeof leadSchema>;
export type Lead = z.output<typeof leadSchema>;Three decisions in this schema are worth unpacking. The phone is accepted in any familiar format and normalized with transform, and only then pipe checks the result with a regex — exactly the "normalizing the format is the code's job" principle from the "Painless forms" lesson. The service starts as an empty string in the form, so the input is z.string() and the output narrows to z.enum. Because of transform and pipe, the input and output types differ, and Zod exposes both: z.input is what the form holds, z.output is what the submit handler receives.
The same schema on the server closes a gap the browser cannot: a request can be sent around the form. z.flattenError turns the error into a flat "field → messages" object that is convenient to return from an API:
import * as z from "zod";
import { leadSchema } from "./lead-schema.ts";
/* the same schema on the server: the browser can be bypassed, the server cannot */
export function parseLead(body: unknown) {
const result = leadSchema.safeParse(body);
if (!result.success) {
return { ok: false as const, fieldErrors: z.flattenError(result.error).fieldErrors };
}
return { ok: true as const, lead: result.data };
}import { parseLead } from "./server-validate.ts";
console.log(parseLead({ name: " Aigerim ", phone: "8 (700) 123-45-67", service: "crm", consent: true }));
console.log(parseLead({ name: "A", phone: "123", service: "", consent: false }));$ node src/server-check.ts
{
ok: true,
lead: {
name: 'Aigerim',
phone: '+77001234567',
service: 'crm',
consent: true
}
}
{
ok: false,
fieldErrors: {
name: [ 'Enter your name' ],
phone: [ 'Enter a phone like +7 700 123 45 67' ],
service: [ 'Choose a service' ],
consent: [ 'Consent is required to send the form' ]
}
}React Hook Form: registering fields and timing errors
React Hook Form (RHF) is built on uncontrolled inputs: register("name") returns name, ref, onChange and onBlur, which you spread onto an <input>. Values live in the DOM and the library's internal store, and the component re-renders only when something it subscribes to changes, such as the error list. A schema is connected through a resolver from the @hookform/resolvers package. The RHF docs warn that with a resolver, built-in rules such as register("x", { required: true }) do not apply — all rules live in the schema.
npm install react-hook-form zod @hookform/resolversThe mode option sets when validation runs, and reValidateMode sets re-validation after the first submit (default onChange):
onSubmit (default)
- When it validates
- On submit, then invalid fields on change
- What the user sees
- Errors only after pressing the button
onBlur
- When it validates
- When the field loses focus
- What the user sees
- The error appears after leaving the field, but also disappears only after leaving it again
onChange
- When it validates
- On every change
- What the user sees
- Complaints from the first character; the RHF docs warn about a significant performance cost
onTouched
- When it validates
- First on blur, then on every change
- What the user sees
- Silence while typing; once shown, the error disappears as soon as the field is fixed
all
- When it validates
- On both blur and change
- What the user sees
- Like
onChange, plus a check on blur
For a lead form we use onTouched: it matches the advice from the "Painless forms" lesson — stay quiet while the person types, validate when they leave the field, and remove the error as soon as it is fixed.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { leadSchema, type Lead, type LeadInput } from "./lead-schema.ts";
type Props = { submitLead: (lead: Lead) => Promise<void> };
const describedBy = (...ids: (string | false)[]) => ids.filter(Boolean).join(" ") || undefined;
export function LeadForm({ submitLead }: Props) {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<LeadInput, unknown, Lead>({
resolver: zodResolver(leadSchema),
mode: "onTouched", // first check on blur, then on every change
defaultValues: { name: "", phone: "", service: "", consent: false },
});
async function onSubmit(lead: Lead) {
try {
await submitLead(lead);
} catch {
setError("root.serverError", {
message: "Could not send the request. Try again or message us on WhatsApp.",
});
}
}
if (isSubmitSuccessful) {
return <p role="status">Thank you! We will call you back within a working day.</p>;
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="lead-name">Name</label>
<input
id="lead-name"
autoComplete="name"
aria-invalid={errors.name ? true : undefined}
aria-describedby={describedBy(!!errors.name && "lead-name-error")}
{...register("name")}
/>
{errors.name && <p id="lead-name-error">{errors.name.message}</p>}
<label htmlFor="lead-phone">Phone</label>
<input
id="lead-phone"
type="tel"
autoComplete="tel"
aria-invalid={errors.phone ? true : undefined}
aria-describedby={describedBy("lead-phone-hint", !!errors.phone && "lead-phone-error")}
{...register("phone")}
/>
<p id="lead-phone-hint">Any format: 8 700…, +7 700…</p>
{errors.phone && <p id="lead-phone-error">{errors.phone.message}</p>}
<label htmlFor="lead-service">Service</label>
<select
id="lead-service"
aria-invalid={errors.service ? true : undefined}
aria-describedby={describedBy(!!errors.service && "lead-service-error")}
{...register("service")}
>
<option value="">Choose…</option>
<option value="website">Website</option>
<option value="web-app">Web application</option>
<option value="crm">CRM</option>
</select>
{errors.service && <p id="lead-service-error">{errors.service.message}</p>}
<input
id="lead-consent"
type="checkbox"
aria-invalid={errors.consent ? true : undefined}
aria-describedby={describedBy(!!errors.consent && "lead-consent-error")}
{...register("consent")}
/>
<label htmlFor="lead-consent">I agree to the processing of my data</label>
{errors.consent && <p id="lead-consent-error">{errors.consent.message}</p>}
{errors.root?.serverError && <p role="alert">{errors.root.serverError.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending…" : "Get a quote"}
</button>
</form>
);
}useForm<LeadInput, unknown, Lead>— the third type parameter describes the data after the schema, soonSubmitreceives the already normalized phone.defaultValuesare set for every field: the RHF docs advise againstundefineddefault values because it conflicts with controlled components.noValidateturns off the browser's built-in bubbles: the form shows errors itself, consistently and with the right text.
Submitting: pending, server error, success
Validation
handleSubmitruns the data through the schema. If there are errors,onSubmitis not called and focus moves to the first invalid field by default (shouldFocusError: true).Pending
Until the promise returned by
onSubmitsettles,formState.isSubmittingistrue: the button is disabled and reads "Sending…". A second click sends nothing — the double-click test checks exactly that.Server error
A network failure or a non-2xx response is caught in
catchand stored withsetError("root.serverError"). Such an error is not tied to a field and, per the RHF docs, does not persist between submissions — a retry starts clean.Success
If the handler finishes without errors,
isSubmitSuccessfulbecomestrueand a message withrole="status"replaces the form.
The request itself is passed in through the submitLead prop. The component does not know where the data goes — tests pass a stub instead of the network, and the app passes a regular fetch that turns a non-2xx response into an exception:
import { LeadForm } from "./LeadForm.tsx";
import type { Lead } from "./lead-schema.ts";
async function submitLead(lead: Lead) {
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(lead),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}
export default function App() {
return <LeadForm submitLead={submitLead} />;
}/api/leads is an example; on the server the body is checked by parseLead from the previous sectionAccessible errors: aria-invalid and aria-describedby
A sighted user sees a red border and text under the field. A screen reader needs more: it must know that the field is invalid and which text belongs to it. The form uses three mechanisms for this, all already present in LeadForm.tsx:
Error state
- In code
aria-invalid="true"only after validation- Why
- The screen reader announces the field as invalid. MDN: do not set it on an empty required field before a submit attempt
Error text and hint
- In code
aria-describedbywith the hint id and the error id- Why
- Links the field to its message, as in the W3C WAI tutorial; the text is read when the field gets focus
Focus after submit
- In code
shouldFocusErrorin RHF (on by default)- Why
- W3C WAI recommends moving focus to the first field with an error
Error without a field
- In code
role="alert"on the server message- Why
- Announced immediately even though focus stays on the button
Success
- In code
role="status"- Why
- A polite announcement that the request was sent
The describedBy helper builds aria-describedby only from ids that exist: the reference to the error element appears together with the element. The phone field has two ids in its description — a permanent format hint and the error when there is one. MDN also describes a separate aria-errormessage attribute; the example uses aria-describedby, as the W3C WAI tutorial does.
Verification: tests and common mistakes
Testing Library tests work the way a user does: find a field by its label, type, leave with Tab, press the button. getByLabelText finds a field only if its <label> is linked to it, and toHaveAccessibleDescription checks that aria-describedby really points at the error text. So the tests check accessibility as a side effect.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { LeadForm } from "./LeadForm.tsx";
async function fillValid(user: ReturnType<typeof userEvent.setup>) {
await user.type(screen.getByLabelText("Name"), "Aigerim");
await user.type(screen.getByLabelText("Phone"), "8 (700) 123-45-67");
await user.selectOptions(screen.getByLabelText("Service"), "crm");
await user.click(screen.getByLabelText(/I agree/));
}
describe("LeadForm", () => {
it("does not complain while typing, shows the error on blur", async () => {
const user = userEvent.setup();
render(<LeadForm submitLead={vi.fn()} />);
const name = screen.getByLabelText("Name");
await user.type(name, "A");
expect(screen.queryByText("Enter your name")).not.toBeInTheDocument();
await user.tab();
expect(await screen.findByText("Enter your name")).toBeInTheDocument();
expect(name).toHaveAttribute("aria-invalid", "true");
expect(name).toHaveAccessibleDescription("Enter your name");
await user.type(name, "igerim"); // after the first blur it re-validates on change
expect(screen.queryByText("Enter your name")).not.toBeInTheDocument();
expect(name).not.toHaveAttribute("aria-invalid");
});
it("on empty submit shows every error and focuses the first invalid field", async () => {
const user = userEvent.setup();
const submitLead = vi.fn();
render(<LeadForm submitLead={submitLead} />);
await user.click(screen.getByRole("button", { name: "Get a quote" }));
expect(await screen.findByText("Choose a service")).toBeInTheDocument();
expect(screen.getByText("Consent is required to send the form")).toBeInTheDocument();
expect(screen.getByLabelText("Name")).toHaveFocus();
expect(screen.getByLabelText("Phone")).toHaveAccessibleDescription(
"Any format: 8 700…, +7 700… Enter a phone like +7 700 123 45 67",
);
expect(submitLead).not.toHaveBeenCalled();
});
it("sends normalized data once and blocks the button while pending", async () => {
const user = userEvent.setup();
let finish!: () => void;
const submitLead = vi.fn(() => new Promise<void>((resolve) => (finish = resolve)));
render(<LeadForm submitLead={submitLead} />);
await fillValid(user);
const button = screen.getByRole("button", { name: "Get a quote" });
await user.dblClick(button);
expect(await screen.findByRole("button", { name: "Sending…" })).toBeDisabled();
expect(submitLead).toHaveBeenCalledTimes(1);
expect(submitLead).toHaveBeenCalledWith({
name: "Aigerim",
phone: "+77001234567",
service: "crm",
consent: true,
});
finish();
expect(await screen.findByRole("status")).toHaveTextContent("Thank you!");
});
it("shows a server error and lets the user retry", async () => {
const user = userEvent.setup();
const submitLead = vi.fn().mockRejectedValueOnce(new Error("HTTP 500")).mockResolvedValueOnce(undefined);
render(<LeadForm submitLead={submitLead} />);
await fillValid(user);
await user.click(screen.getByRole("button", { name: "Get a quote" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not send the request");
await user.click(screen.getByRole("button", { name: "Get a quote" }));
expect(await screen.findByRole("status")).toBeInTheDocument();
expect(submitLead).toHaveBeenCalledTimes(2);
});
});$ npx vitest run src/LeadForm.test.tsx --reporter=verbose
✓ src/LeadForm.test.tsx > LeadForm > does not complain while typing, shows the error on blur
✓ src/LeadForm.test.tsx > LeadForm > on empty submit shows every error and focuses the first invalid field
✓ src/LeadForm.test.tsx > LeadForm > sends normalized data once and blocks the button while pending
✓ src/LeadForm.test.tsx > LeadForm > shows a server error and lets the user retry
Test Files 1 passed (1)
Tests 4 passed (4)value without onChange
- Symptom
- You cannot type into the field; a console warning
- Fix
- Add
onChangeor switch todefaultValue
undefined as the initial value
- Symptom
- Warning about switching from uncontrolled to controlled
- Fix
- An empty string in
useState("")anddefaultValues
pattern not escaped for the v flag
- Symptom
- The constraint silently does nothing
- Fix
- Escape
(,),-and other characters inside the class
register("x", { required: true }) together with a resolver
- Symptom
- The rule is ignored
- Fix
- Move the rule into the schema
mode: "onChange" for the whole form
- Symptom
- Error on the first character, extra re-renders
- Fix
onTouchedoronBlur
Validation in the browser only
- Symptom
- Invalid data arrives through a direct request
- Fix
safeParsewith the same schema on the server
How this was verified5 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.