frontend · level 7

Forms & Validation

Native HTML, schema libs, and the UX of telling users they're wrong.

200 XP

Forms & Validation

A form is an honest place where users meet the validation rules a system has chosen to enforce. The choice that makes or breaks the experience isn't what you validate — it's when you tell them, where you put the message, and how you let them recover.

Analogy

Picture two passport-control queues. The first inspector reads every page of your passport while you're still walking up to the counter and shouts every problem ("missing visa!" "exit stamp wrong!" "form not signed!") before you can answer. The second inspector waits until you stop in front of the desk, looks at the whole document calmly, points at the one thing that's wrong, and lets you fix it. Same rules, same compliance — wildly different feeling. The second one is on-blur validation; the first one is on-change.

Native HTML covers more than you think

Before reaching for any library, the browser already validates:

<input
  type="email"
  required
  autoComplete="email"
  maxlength="200"
  aria-describedby="email-help"
/>
<p id="email-help">We'll only use this for sign-in.</p>

<input
  type="password"
  required
  minlength="8"
  pattern="(?=.*\d).*"
  title="Must contain a number"
/>

type="email", required, pattern, minlength, maxlength, min / max for numbers and dates — all of these participate in the Constraint Validation API. Submit a form with invalid fields and the browser blocks submission, focuses the first failing field, and shows its built-in tooltip. You can read field.validity.valueMissing, field.validity.typeMismatch, etc. from JS to render your own messages.

The browser email regex is RFC-compliant. The one you wrote in five minutes is not.

When to validate

Three timings, three jobs:

  • On change. Fires every keystroke. Reserve for positive feedback — a green check on a well-formed field, a strength meter on a password. Almost never use it for errors unless you want to feel like a paperclip from 2003.
  • On blur. Fires when the user leaves the field. The right default for individual-field errors. Gives the user space to finish typing before judging them.
  • On submit. Fires when the user clicks Save. The last gate. Validates the whole form (cross-field rules, server-side checks, anything you couldn't do per-field). Always run it; never rely on it alone.

A common mature pattern: validate on blur the first time, then once a field has been touched and shown an error, switch to on change so the error clears the moment they fix it. react-hook-form calls this mode: "onTouched".

react-hook-form + zod is the 2024 default

Two libraries that pair so well they're effectively one stack:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const Schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword, {
  path: ["confirmPassword"],
  message: "Passwords must match",
});

const { register, handleSubmit, formState: { errors } } = useForm({
  resolver: zodResolver(Schema),
  mode: "onBlur",
});

What you get:

  • One schema for client validation, server validation (FastAPI / tRPC / Hono all accept zod), and TypeScript types (z.infer).
  • Uncontrolled inputs via refs — react-hook-form doesn't re-render the whole form on every keystroke.
  • Cross-field rules with .refine().
  • Async rules (is this username taken?) with .superRefine().

For Formik / Yup codebases that pre-date this stack, the same ideas apply — just less ergonomic than zod.

Showing errors

Three rules, in priority order:

  1. Put the error next to the field. Not at the top of the form, not in a toast. The user's eyes are already on the field.
  2. Use role="alert" or aria-live="polite" so screen readers announce the error when it appears.
  3. Keep the error visible while the user fixes it. Don't clear it on focus. Clear it when the field becomes valid.
<label htmlFor="email">Email</label>
<input
  id="email"
  type="email"
  aria-invalid={!!errors.email}
  aria-describedby={errors.email ? "email-error" : undefined}
  {...register("email")}
/>
{errors.email && (
  <p id="email-error" role="alert" className="text-danger">
    {errors.email.message}
  </p>
)}

aria-invalid styles the field; aria-describedby ties it to the error text. Both matter for screen readers and for keyboard-only users who never see the colour change.

Optimistic submission

For a fast UX with a slow API, trust the client first and reconcile later:

function SaveSettings({ initial }: { initial: Settings }) {
  const [optimistic, setOptimistic] = useState(initial);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(next: Settings) {
    setOptimistic(next);            // UI updates immediately
    setSaving(true);
    setError(null);
    try {
      await fetch("/api/settings", { method: "PUT", body: JSON.stringify(next) });
    } catch (err) {
      setOptimistic(initial);       // roll back on failure
      setError("Couldn't save. Try again.");
    } finally {
      setSaving(false);
    }
  }
  // ...
}

Works beautifully when failures are rare. Tools like TanStack Query bake this in (useMutation with onMutate / onError / onSettled). For high-stakes forms (payment, password change), prefer pessimistic — wait for the server before showing success.

Validation on the server is mandatory

Client validation is UX. Server validation is security.

  • A motivated attacker uses curl and bypasses every client-side rule.
  • A buggy client (or an old cached bundle) can submit invalid data unintentionally.
  • Cross-tenant rules (uniqueness, permission checks, business invariants) only the server can enforce.

The win of zod / valibot / pydantic is that you write one schema and run it on both ends. The client gets instant feedback; the server gets the same parsing and the same error shape.

Common bugs

Re-renders on every keystroke. Controlled inputs in a 30-field form re-render everything. Use react-hook-form's uncontrolled mode or memoize per-field components.

Errors that swallow the field. A red border + red text + red background = unreadable. Keep the field readable; only the message turns red.

Submit button disabled while invalid. Tempting, but cruel — the user has no idea why they can't submit, especially if errors haven't been shown yet. Better: keep the button enabled, run validation on submit, and then show errors.

Auto-focus the first error. On submit, after validation, move focus to the first invalid field. Without this, keyboard users have to hunt for the error.

useEffect(() => {
  const firstError = Object.keys(errors)[0];
  if (!firstError) return;
  document.getElementById(firstError)?.focus();
}, [errors]);

Forgetting autoComplete. A login form without autoComplete="email" and autoComplete="current-password" breaks password managers. Users notice immediately.

Practical checklist

  • Native attributes first: type, required, pattern, minlength, autoComplete.
  • One schema (zod / pydantic) shared between client and server.
  • mode: "onBlur" (or "onTouched") on the form.
  • Error message next to the field, with role="alert" + aria-describedby.
  • Auto-focus the first invalid field on submit.
  • Loading state on the submit button while the request is in flight.
  • Server returns the same error shape as the client expects.

Tools in the wild

5 tools
  • Uncontrolled inputs via refs; small, fast, and the de-facto React form library since 2022.

    library
  • Zodfree tier

    TypeScript-first schema validation; same schema validates client + server and infers your types.

    library
  • Bridge between react-hook-form and zod / yup / valibot / arktype — keep one schema, use it everywhere.

    library
  • Formikfree tier

    The pre-2022 React form leader; controlled-input model, still in many existing codebases.

    library
  • Native browser validation: required, type, pattern, minlength, setCustomValidity. Free and underused.

    spec