How to buildOctober 11, 202610 min read
ReactStep 5 of 8

Forms and validation in React

In short

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.

How-toLevel: IntermediateForms & ValidationPractice: ~1–2 h

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-invalid and aria-describedby and 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
  1. 01Controlled and uncontrolled inputs
  2. 02Native HTML validation and its limits
  3. 03A Zod schema: one source of truth for client and server
  4. 04React Hook Form: registering fields and timing errors
  5. 05Submitting: pending, server error, success
  6. 06Accessible errors: aria-invalid and aria-describedby
  7. 07Verification: 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 (checked for checkboxes)
Uncontrolled
defaultValue / defaultChecked, plus a required name

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
src/CommentField.tsx
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>
  );
}
A controlled field that earns its keep: the counter updates on every keystroke

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.

src/NativeLeadForm.tsx
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>
  );
}
Uncontrolled fields, browser validation and reading the data through FormData

For 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-invalid page.
  • 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.

src/lead-schema.ts
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>;
The lead schema: name, phone in any format, service and consent

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:

src/server-validate.ts
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 };
}
Validating the request body on the server with the same schema
src/server-check.ts
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 }));
terminal
$ 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' ]
  }
}
Real Node.js 24 output: the phone is normalized, the name trimmed, errors grouped by field

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.

bash
npm install react-hook-form zod @hookform/resolvers

The 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.

src/LeadForm.tsx
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>
  );
}
The lead form: schema, error timing, accessible errors and submit state
  • useForm<LeadInput, unknown, Lead> — the third type parameter describes the data after the schema, so onSubmit receives the already normalized phone.
  • defaultValues are set for every field: the RHF docs advise against undefined default values because it conflicts with controlled components.
  • noValidate turns off the browser's built-in bubbles: the form shows errors itself, consistently and with the right text.

Submitting: pending, server error, success

  1. Validation

    handleSubmit runs the data through the schema. If there are errors, onSubmit is not called and focus moves to the first invalid field by default (shouldFocusError: true).

  2. Pending

    Until the promise returned by onSubmit settles, formState.isSubmitting is true: the button is disabled and reads "Sending…". A second click sends nothing — the double-click test checks exactly that.

  3. Server error

    A network failure or a non-2xx response is caught in catch and stored with setError("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.

  4. Success

    If the handler finishes without errors, isSubmitSuccessful becomes true and a message with role="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:

src/App.tsx
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 section

Accessible 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-describedby with 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
shouldFocusError in 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.

src/LeadForm.test.tsx
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);
  });
});
Four scenarios: error on blur, empty submit, double click and server error
terminal
$ 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)
Real Vitest output (timings omitted); a Vite react-ts project with the jsdom environment. The native form, the counter and the server schema are tested too — 8 tests in total

value without onChange

Symptom
You cannot type into the field; a console warning
Fix
Add onChange or switch to defaultValue

undefined as the initial value

Symptom
Warning about switching from uncontrolled to controlled
Fix
An empty string in useState("") and defaultValues

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
onTouched or onBlur

Validation in the browser only

Symptom
Invalid data arrives through a direct request
Fix
safeParse with 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.