🪴 GoDeep
Search
← Shipping Software

Forms and validation in React

Controlled inputs and why big forms re-render, react-hook-form and zod, sharing one schema between the form and your FastAPI types, and putting a server's 422 on the field that caused it.

13 cards · 6 quiz questions · 11 min read

An agent built the form that creates a survey wave: twenty-odd fields, quotas, a client name, an email for the notification. It works, but typing in it feels sticky, the email validation lives in three places that disagree, and when FastAPI rejects the submission you get a red toast saying “Request failed” with no clue which field was wrong. Forms are where a front end’s sloppiness becomes most visible, because a human is standing in front of it typing.

Controlled inputs, and why big forms re-render

A controlled input takes its value from React state and reports every keystroke back:

// The version agents emit: one state object in the parent
const [form, setForm] = useState({ client: "", email: "", quota: 0 });

<input
  value={form.email}
  onChange={(e) => setForm({ ...form, email: e.target.value })}
/>

React now owns the text, which is tidy. The cost is that each character calls setForm, which re-renders the component that owns the state — and therefore every field inside it. With three fields you’d never notice. With thirty, plus a date picker and a table preview, you get visible lag between pressing a key and seeing the letter.

An uncontrolled input leaves the value in the DOM node and reads it through a ref when needed. Typing causes no React render at all. That’s the trade: controlled gives you the value on every keystroke (needed for live-filtering a list), uncontrolled gives you speed and simplicity (right for almost every form).

Two legitimate fixes exist. Push the state down, so each field owns its own useState and only that field re-renders. Or use uncontrolled inputs and read them on submit — which is what react-hook-form does for you, with validation attached.

react-hook-form basics

import { useForm } from "react-hook-form";

type WaveForm = { client: string; email: string; quota: number };

function NewWave() {
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<WaveForm>({
    defaultValues: { client: "", email: "", quota: 500 },
    mode: "onTouched",   // validate when a field loses focus, not per keystroke
  });

  const onSubmit = async (values: WaveForm) => { await createWave(values); };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <input {...register("client")} />
      <button type="submit" disabled={isSubmitting}>Create wave</button>
    </form>
  );
}

Three pieces do the work. register("client") returns name, onChange, onBlur and ref props that you spread onto the input; that registers the DOM node with the form without storing its value in React state. handleSubmit prevents the page reload, runs validation, and calls your function only with valid values. formState holds errors, isSubmitting, isValid and friends — and re-rendering is scoped to the parts that changed, which is the whole performance argument.

mode decides when validation runs: onSubmit is the default, and onTouched is usually kindest. Telling someone their email is invalid after they’ve typed o is hostile; telling them when they leave the field is helpful.

Schema validation with zod

Hand-written rules per field drift apart. A schema is one runtime description of the whole shape:

import * as z from "zod";

export const WaveSchema = z.object({
  client: z.string().min(2, "Client name is required"),
  email: z.email("That doesn't look like an email address"),
  quota: z.number().int().min(1, "Quota must be at least 1").max(5000),
  mode: z.enum(["online", "cati", "mixed"]),
  notes: z.string().max(500).optional(),
});

export type WaveForm = z.infer<typeof WaveSchema>;  // the type, derived not duplicated

z.infer is the point. A TypeScript type exists only at compile time and vanishes at runtime; a zod schema exists at runtime and can hand you the type. Deriving the type from the schema is the only direction in which the two cannot drift apart. The pattern to be suspicious of is an agent writing a hand-maintained interface and a separate set of checks — the day someone adds a field to one, the other lies.

Wire the schema to the form with a resolver:

import { zodResolver } from "@hookform/resolvers/zod";

const form = useForm<WaveForm>({
  resolver: zodResolver(WaveSchema),
  defaultValues: { client: "", email: "", quota: 500, mode: "online" },
});

The resolver runs the schema over the form values and turns each zod issue into a per-field error, keyed by the issue’s path. You now have one source of truth for what a valid wave is.

The same schema is worth using on responses coming back from your API, where safeParse is better than parse because you can branch instead of catching:

const result = WaveSchema.safeParse(await res.json());
if (!result.success) {
  // error.issues lists path/message per problem — log it, don't render it
  reportSchemaDrift(result.error.issues);
}

That check is how you find out the back end renamed a field, rather than discovering it as undefined in a table cell.

Showing FastAPI’s 422 on the right field

Client-side validation is a courtesy. The server validates independently, and when Pydantic rejects a body FastAPI returns 422 with a detail array — one entry per problem, each carrying loc (the path to the field), msg and a machine-readable type:

{
  "detail": [
    { "loc": ["body", "email"], "msg": "value is not a valid email address", "type": "value_error" },
    { "loc": ["body", "quota"], "msg": "Input should be less than or equal to 5000", "type": "less_than_equal" }
  ]
}

The agent’s version throws that away:

catch (e) { toast.error("Request failed"); }   // which of 20 fields? who knows

The useful version maps each loc back onto a field name. Drop the leading "body" segment, join the rest with dots (react-hook-form understands respondents.0.email for nested fields), and call setError:

const onSubmit = async (values: WaveForm) => {
  const res = await fetch("/api/waves", { method: "POST", body: JSON.stringify(values) });
  if (res.status === 422) {
    const { detail } = await res.json();
    for (const issue of detail) {
      const field = issue.loc.slice(1).join(".");   // ["body","email"] -> "email"
      setError(field as keyof WaveForm, { type: "server", message: issue.msg });
    }
    return;
  }
  if (!res.ok) setError("root.serverError", { type: "server", message: "Could not save. Try again." });
};

Two details matter. A 422 must never be retried — the same body will fail identically, so if this call goes through TanStack Query, exclude 4xx from the retry policy. And anything you can’t map to a field belongs on root.serverError, rendered above the submit button, rather than silently dropped. Root errors don’t persist across submissions, which is what you want.

Submit states and double submits

formState.isSubmitting is true from the moment validation passes until the promise your submit function returns settles. That last clause is where agents trip:

// Wrong: returns undefined, so isSubmitting flickers and the button never locks
handleSubmit((values) => { createWave(values); });

// Right: the promise is awaited, so isSubmitting spans the request
handleSubmit(async (values) => { await createWave(values); });

Disable the button on isSubmitting and you’ve handled the double click. You have not handled a refresh mid-request, a network retry, or two open tabs. Anything that creates a record needs the server to be the guarantor: a unique constraint on something meaningful, or an idempotency key — a UUID the client generates once per submission attempt and sends as a header, which the server records so a repeat of the same key returns the original result instead of creating a second wave.

Accessible labels and errors

<label htmlFor="email">Notification email</label>
<input
  id="email"
  {...register("email")}
  aria-invalid={errors.email ? "true" : undefined}
  aria-describedby={errors.email ? "email-error" : undefined}
/>
<p id="email-error" role="alert">{errors.email?.message}</p>

Four things are doing work here. A real <label> with htmlFor matching the input’s id means clicking the label focuses the field and a screen reader announces it — placeholder text is not a label, and vanishes the moment you type. aria-invalid marks the field as wrong. aria-describedby ties the message to the input, so the error is read out with the field rather than floating unconnected. role="alert" (or an aria-live="polite" container) makes a newly appearing message announced.

A red border alone communicates nothing to a screen reader, and nothing to roughly one man in twelve.

What to check in your own code

  • Count the useState calls in your biggest form component. More than a handful in one component, all updated on change, is your lag.
  • Grep for placeholder= on inputs with no matching <label htmlFor>. Each one is an unlabelled field.
  • Grep for 422. If it appears nowhere in the front end, server validation errors are being shown as a generic failure.
  • Find your submit handlers and check each is async with an await, not fire-and-forget.
  • Look for a hand-written interface for form values alongside a zod schema for the same thing. Delete the interface and use z.infer.
  • Double-click a create button on a slow connection and count the rows created. If it’s two, add the server-side constraint, not only the disabled attribute.
  • Tab through the form with the keyboard and trigger one error. Is the error reachable and readable, or is it colour only?

Sources

  • react-hook-form — useForm website Options (defaultValues, mode, resolver) and the returned register, handleSubmit and formState including errors, isSubmitting and isValid.
  • react-hook-form — setError website Signature for putting a server error on a named field, and the root.serverError convention for form-level errors.
  • Zod — Basics website z.object, safeParse's result shape, error.issues and z.infer for deriving a TypeScript type from a schema.
  • FastAPI — Handling Errors website Request validation failures return 422 with a detail array of objects carrying loc, msg and type.
  • MDN — aria-describedby website How an input is wired to its error message so screen readers announce it.