Topic #27Core17 min read

Form Handling & Validation

Forms from first principles: controlled vs uncontrolled inputs, native HTML5 validation, why React Hook Form minimises re-renders, schema validation with Zod that doubles as your TypeScript type, error display, and accessibility — beginner to advanced in one page.

#forms#react-hook-form#zod#validation#typescript#controlled-components#uncontrolled#accessibility#html5-validation

Why forms are the hard part of UIs. A form is where a user's intent becomes your application's data, so it's where correctness, validation, error messaging, accessibility, and performance all collide. Get it wrong and users hit confusing errors, screen-reader users are lost, or a giant form janks on every keystroke. The good news: a few clear concepts — controlled vs uncontrolled, native then schema validation, and accessible error wiring — cover the vast majority of real forms.

Controlled inputs. A controlled input stores its value in React state and pushes it back into the input via value, updating on every keystroke through onChange. React is the single source of truth: const [email, setEmail] = useState(''); <input value={email} onChange={e => setEmail(e.target.value)} />. This makes the value trivially available for validation, conditional rendering, and derived UI — but it re-renders the component on every character, which can matter for very large forms.

Uncontrolled inputs. An uncontrolled input keeps its value in the DOM itself; React doesn't track it on each keystroke. You read the value only when you need it — typically on submit — via a ref: const ref = useRef(); ... ref.current.value. Set an initial value with defaultValue (not value). Uncontrolled inputs are lighter (no re-render per keystroke) and are exactly how React Hook Form scales, but the live value isn't in React state, so instant cross-field logic is harder.

Which to choose. Reach for controlled when you need the value live — inline validation as they type, a character counter, enabling a button, or one field reacting to another. Reach for uncontrolled (or a library that uses it) for large forms where per-keystroke re-renders would be wasteful, or when you only care about the final values at submit. In practice, teams standardise on React Hook Form, which gives the ergonomics of controlled forms with the performance of uncontrolled ones.

Native HTML5 validation — the free baseline. The browser already validates a lot for free: required, type="email"/type="url", min/max/step for numbers, minlength/maxlength, and pattern with a regex. These give you built-in error bubbles, block submission, and expose the Constraint Validation API (input.validity, setCustomValidity) plus the :invalid/:valid CSS pseudo-classes for styling. Always start here — it's zero JS, accessible by default, and works before your bundle even loads.

Why native alone isn't enough. Native validation can't easily express cross-field rules ('password confirmation must match password'), server-driven errors ('email already taken'), or richly styled inline messages, and its default bubbles are inconsistent across browsers. So the pattern is layered: native attributes for the cheap baseline, then a JS/schema layer for the rich rules and custom UI. Crucially, both are only UX — the server must re-validate everything because anyone can bypass the client with a raw HTTP request.

React Hook Form — performance through refs. React Hook Form (RHF) registers each input by ref via {...register('email')} instead of binding it to controlled state, so typing in a field doesn't re-render the whole form — only the fields that need to (like the one showing an error) update. handleSubmit(onSubmit) runs validation and, only if it passes, calls your handler with a typed values object. formState exposes errors, isSubmitting, isDirty, touchedFields, and more. For third-party controlled components (a custom Select, a date picker), you bridge them with the <Controller> component.

Schema validation with Zod — one source of truth. Rather than scattering if checks, you declare the shape and rules of your data once as a Zod schema: z.object({ email: z.string().email(), age: z.number().min(18) }). At runtime the schema validates and even coerces/parses input; at compile time type FormData = z.infer<typeof schema> extracts the exact TypeScript type from that same schema. Because there's a single source of truth, your validation rules and your static types can never drift apart — a duplication bug that plagues hand-written validation.

Wiring Zod into RHF. The @hookform/resolvers/zod package provides zodResolver(schema), which you pass as useForm({ resolver: zodResolver(schema) }). Now RHF runs your Zod schema on submit (or on blur/change if configured via mode), and any failures land in formState.errors keyed by field, with the message you supplied in the schema. Cross-field rules use .refine() / .superRefine() — e.g. confirm-password matching — and attach the error to the relevant field.

Displaying errors accessibly. Showing a red message isn't enough — assistive tech must announce it and associate it with its field. Give the input aria-invalid={!!errors.email} and aria-describedby="email-error", and render the message in an element with that id and role="alert" (or inside an aria-live region) so it's announced when it appears. Also point focus at the first invalid field on a failed submit. Every input needs a real, associated <label> (via htmlFor/id or wrapping) — a placeholder is not a label.

Validation timing and UX. Validating on every keystroke from the first character is hostile — the user sees 'invalid email' before they've finished typing. The friendly pattern: validate on submit first, then switch a field to on-change validation after it's first been touched or has errored (RHF's mode: 'onTouched' or reValidateMode). Disable the submit button while isSubmitting, show a spinner, and surface server errors by mapping the response back onto fields with setError.

Multi-step forms and drafts. For wizards, keep all step values in one form/state object and only validate the current step's fields on Next; validate the whole schema on final submit. Persisting a draft (to localStorage or the server) protects against accidental navigation. Reset the form after a successful submit with reset() so stale values don't linger, and clear any server errors.

The mental model (memorise this). Controlled = value in React state, re-renders per keystroke, use when you need the value live; uncontrolled = value in the DOM read via ref, cheaper, how RHF scales. Layer validation: native HTML attributes for the free baseline → a Zod schema for rich, cross-field rules and the derived TypeScript type → zodResolver feeds failures into formState.errors. Display errors accessibly (label + aria-invalid + aria-describedby + role=alert), validate on submit then progressively, and always re-validate on the server — the client is convenience, never a security boundary.

Backend Analogy

A Zod schema is your Java Bean Validation (`@NotNull`, `@Email`, `@Min(18)`, `@Pattern`) — declarative constraints on a data shape — except the *same* object also generates the TypeScript type, the way a record/DTO class defines both the fields and their validation in one place. `zodResolver` plugging the schema into the form is like a `@Valid` parameter triggering the validator before your controller method body runs, with the failures collected into a `BindingResult` (that's `formState.errors`). React Hook Form managing dirty/touched/submitting state without re-rendering is analogous to a form-binding framework tracking field metadata cheaply. And the non-negotiable rule mirrors the backend exactly: client validation is UX, server validation is the trust boundary — never trust input that crossed the wire, because a `curl` request skips every browser check just like it skips your JavaScript.

Key Insights
  • Controlled inputs store the value in React state and re-render per keystroke; uncontrolled inputs keep it in the DOM and you read it via a ref, typically on submit.
  • Use controlled when you need the value live (inline validation, counters, cross-field logic); use uncontrolled or React Hook Form for large forms to avoid per-keystroke re-renders.
  • Start with native HTML5 validation (required, type, min/max, pattern) — it's free, accessible, and works before your JS loads.
  • React Hook Form registers inputs by ref so typing doesn't re-render the whole form; handleSubmit validates then calls your handler with typed values.
  • One Zod schema is both the runtime validator and the static type via z.infer, so rules and types can never drift apart.
  • zodResolver(schema) bridges Zod and React Hook Form; failures populate formState.errors keyed by field with your messages.
  • Cross-field rules (password confirmation) use Zod .refine()/.superRefine(); native validation can't express them.
  • Display errors accessibly: label + aria-invalid + aria-describedby + role='alert', and move focus to the first invalid field.
  • Validate on submit first, then progressively on touched/change; disable the submit button while isSubmitting and reset() after success.
  • Client validation is UX only — always re-validate on the server, because a raw HTTP request bypasses every browser and JS check.

Worked Code

Controlled vs uncontrolled inputs (the core distinction)
TSX
import { useState, useRef } from 'react';

// CONTROLLED: value lives in React state; re-renders on every keystroke.
function ControlledField() {
  const [email, setEmail] = useState('');
  const valid = /.+@.+\..+/.test(email);
  return (
    <>
      <label htmlFor="c-email">Email</label>
      <input
        id="c-email"
        value={email}                               // React is source of truth
        onChange={(e) => setEmail(e.target.value)}  // update state each keystroke
      />
      {/* value is available live -> instant feedback */}
      {email && !valid && <span role="alert">Enter a valid email</span>}
    </>
  );
}

// UNCONTROLLED: value lives in the DOM; read it via a ref, only when needed.
function UncontrolledField() {
  const emailRef = useRef<HTMLInputElement>(null);
  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    console.log('submitted value:', emailRef.current?.value); // read on submit
  }
  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="u-email">Email</label>
      {/* defaultValue (NOT value) seeds an uncontrolled input */}
      <input id="u-email" ref={emailRef} defaultValue="" />
      <button type="submit">Save</button>
    </form>
  );
}
Step 1 — Zod schema = validation + TypeScript type
TypeScript
import { z } from 'zod';

// A single source of truth: runtime validation AND the static type.
export const signupSchema = z
  .object({
    email: z.string().email('Enter a valid email'),
    age: z.coerce.number().min(18, 'Must be 18 or older'), // coerce "18" -> 18
    password: z.string().min(8, 'At least 8 characters'),
    confirm: z.string(),
    role: z.enum(['user', 'admin']),
  })
  // Cross-field rule: something native validation cannot express.
  .refine((data) => data.password === data.confirm, {
    message: 'Passwords do not match',
    path: ['confirm'], // attach the error to the confirm field
  });

// Derive the form's TypeScript type from the SAME schema — no duplication.
export type SignupForm = z.infer<typeof signupSchema>;
Step 2 — React Hook Form + zodResolver + accessible errors
TSX
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signupSchema, type SignupForm } from './schema';

function SignupForm({ onDone }: { onDone: (d: SignupForm) => Promise<void> }) {
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<SignupForm>({
    resolver: zodResolver(signupSchema),
    mode: 'onTouched',                 // validate after a field is touched, then on change
    defaultValues: { role: 'user' },
  });

  async function onSubmit(data: SignupForm) {
    try {
      await onDone(data);              // handleSubmit only calls this if VALID
      reset();                          // clear the form after success
    } catch (err) {
      // Map a server error back onto a field (e.g. "email already taken")
      setError('email', { message: 'That email is already registered' });
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        {...register('email')}
        aria-invalid={!!errors.email}                 // announce invalid state
        aria-describedby={errors.email ? 'email-err' : undefined}
      />
      {errors.email && (
        <span id="email-err" role="alert">{errors.email.message}</span>
      )}

      <label htmlFor="age">Age</label>
      <input id="age" type="number" {...register('age')} />
      {errors.age && <span role="alert">{errors.age.message}</span>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating…' : 'Create account'}
      </button>
    </form>
  );
}
Native HTML5 validation + Constraint Validation API
HTML
<!-- The free, accessible baseline — works before any JS loads. -->
<form id="signup">
  <label for="email">Email</label>
  <!-- required + type=email + pattern block submission and show native bubbles -->
  <input id="email" name="email" type="email" required
         placeholder="you@example.com" />

  <label for="pw">Password</label>
  <input id="pw" name="pw" type="password" required minlength="8" />

  <label for="qty">Quantity</label>
  <input id="qty" name="qty" type="number" min="1" max="10" step="1" required />

  <button type="submit">Sign up</button>
</form>

<style>
  /* Style validity states with CSS pseudo-classes — no JS needed */
  input:invalid { border-color: #dc2626; }
  input:valid   { border-color: #16a34a; }
</style>
Cross-field rule + custom message via the Constraint Validation API
JavaScript
// Enhance native validation with a JS cross-field rule (confirm password).
const form = document.getElementById('signup');
const pw = form.elements['pw'];
const confirm = form.elements['confirm'];

function checkMatch() {
  // setCustomValidity('') clears the error; a non-empty string marks invalid.
  confirm.setCustomValidity(
    confirm.value === pw.value ? '' : 'Passwords do not match'
  );
}
pw.addEventListener('input', checkMatch);
confirm.addEventListener('input', checkMatch);

form.addEventListener('submit', (e) => {
  // reportValidity() runs all native + custom rules and shows messages.
  if (!form.checkValidity()) {
    e.preventDefault();
    form.reportValidity();
    // Move focus to the first invalid field for accessibility.
    form.querySelector(':invalid')?.focus();
  }
});

Try It Live

Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.

Native + custom form validation, live (no libraries)

Interview-Ready Q&A

A controlled input stores its value in React state and updates on every keystroke via onChange, so React is the single source of truth — the value is available live for validation and derived UI, but the component re-renders on each change. An uncontrolled input keeps its value in the DOM and you read it via a ref (usually on submit), seeded with defaultValue instead of value; it's cheaper because there's no per-keystroke re-render. React Hook Form leans on the uncontrolled approach (registering inputs by ref) to keep large forms fast.

Things to Remember
  • 1Controlled = value in React state, re-renders per keystroke, use when you need the value live; uncontrolled = value in DOM via ref, read on submit.
  • 2React Hook Form registers inputs by ref so typing doesn't re-render the whole form; handleSubmit runs validation then calls your handler.
  • 3Start with native HTML5 validation (required, type, min/max, pattern) — free, accessible, works before JS loads.
  • 4One Zod schema = runtime validation + static type via z.infer; define rules and types once.
  • 5zodResolver(schema) feeds failures into formState.errors keyed by field; cross-field rules use .refine()/.superRefine().
  • 6Accessible errors: real label + aria-invalid + aria-describedby + role='alert', and focus the first invalid field.
  • 7Validate on submit first, then progressively (mode:'onTouched'); disable submit while isSubmitting; reset() after success.
  • 8Use setError to surface server errors on the matching field.
  • 9For multi-step forms keep one values object, validate the current step on Next and the whole schema on final submit.
  • 10Client validation is UX only — always re-validate on the server; a raw HTTP request bypasses every browser and JS check.

References & Further Reading