Week 5: Forms, Controlled Inputs & Validation

Forms are where a lot of React apps first feel genuinely tedious to hand-roll — one useState per field, one onChange per field, validation logic scattered everywhere. This week starts with the raw, controlled-input version so you understand exactly what's happening, then introduces React Hook Form and Zod, the pair of libraries that make real forms manageable.

Module 4 of 17 Week 5 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain the difference between controlled and uncontrolled inputs
  • Build a multi-field form with React Hook Form and Zod validation
  • Surface validation errors accessibly, tied to their field

1. Controlled vs. Uncontrolled Inputs

In a controlled input, React state is the single source of truth — the input's value comes from state, and every keystroke updates that state via onChange. This is the pattern you already used in Week 1's exercise:

controlled.tsx
function ControlledInput() {
  const [value, setValue] = useState('');

  return (
    <input
      value={value}
      onChange={e => setValue(e.target.value)}
    />
  );
}

An uncontrolled input lets the DOM manage its own value; React only reads it when needed, via a ref, instead of re-rendering on every keystroke:

uncontrolled.tsx
function UncontrolledInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    console.log(inputRef.current?.value); // read only at submit time
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={inputRef} defaultValue="" />
      <button type="submit">Submit</button>
    </form>
  );
}

Controlled inputs give you validate-as-you-type, conditionally disabled submit buttons, and formatted input (like auto-inserting dashes in a phone number) — at the cost of a re-render on every keystroke. Uncontrolled inputs skip that re-render entirely, which matters for large forms, but you lose real-time reactivity to what's being typed. React Hook Form (Section 3) gets you the ergonomics of controlled inputs without paying the re-render cost on every keystroke — which is exactly why it's the standard choice for real forms.

2. Handling Multiple Fields Without Boilerplate

A separate useState per field works for two fields and becomes repetitive fast. The common hand-rolled pattern collapses fields into one state object with a single generic change handler:

SignupForm.tsx
interface FormValues {
  name: string;
  email: string;
  password: string;
}

function SignupForm() {
  const [values, setValues] = useState<FormValues>({
    name: '',
    email: '',
    password: '',
  });

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const { name, value } = e.target;
    setValues(prev => ({ ...prev, [name]: value })); // computed property key
  }

  return (
    <form>
      <input name="name" value={values.name} onChange={handleChange} />
      <input name="email" value={values.email} onChange={handleChange} />
      <input name="password" type="password" value={values.password} onChange={handleChange} />
    </form>
  );
}

This works, and it's worth building by hand once to see exactly what a form library automates for you. The name attribute on each input matches a key in FormValues, letting one handleChange update the right field via {'[name]: value'}. What's still missing: validation, error state per field, and tracking which fields the user has actually interacted with — all of which gets tedious fast by hand, and is exactly what Section 3 replaces.

3. React Hook Form

React Hook Form manages form state internally using uncontrolled inputs and refs under the hood, so typing doesn't trigger a re-render of your whole form — only validation errors and submission state do. You interact with it through register and handleSubmit.

terminal
npm install react-hook-form
SignupForm.tsx — with React Hook Form
import { useForm } from 'react-hook-form';

interface FormValues {
  name: string;
  email: string;
  password: string;
}

function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>();

  function onSubmit(data: FormValues) {
    console.log('submitting', data); // only called once validation passes
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name', { required: 'Name is required' })} />
      {errors.name && <span>{errors.name.message}</span>}

      <input {...register('email', { required: 'Email is required' })} />
      {errors.email && <span>{errors.email.message}</span>}

      <button type="submit" disabled={isSubmitting}>Sign up</button>
    </form>
  );
}

register('name', {'{ required: ... }'}) returns a bundle of props (name, onChange, onBlur, ref) that the spread operator ({'{...register(...)}'}) attaches to the input in one line — that's the mechanism letting React Hook Form track the field via an uncontrolled ref instead of controlled state. handleSubmit(onSubmit) runs all validation first, and only calls your onSubmit if every field passes.

4. Schema Validation with Zod

Inline rules like {'{ required: ... }'} work for simple cases, but get unwieldy for real validation — password strength, matching fields, cross-field rules. Zod lets you describe your data's shape and constraints once, as a schema, and reuse that same schema for both validation and TypeScript types.

terminal
npm install zod @hookform/resolvers
signupSchema.ts
import { z } from 'zod';

export const signupSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Enter a valid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

// Infer the TypeScript type directly from the schema -- one source of truth
export type SignupValues = z.infer<typeof signupSchema>;
SignupForm.tsx — wired to Zod
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signupSchema, type SignupValues } from './signupSchema';

function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignupValues>({
    resolver: zodResolver(signupSchema),
  });

  function onSubmit(data: SignupValues) {
    console.log('valid data:', data);
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} />
      {errors.name && <span>{errors.name.message}</span>}

      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}

      <button type="submit" disabled={isSubmitting}>Sign up</button>
    </form>
  );
}

The resolver option hands validation entirely to Zod — React Hook Form runs your schema on submit (and, if configured, on blur/change) and populates errors from whatever Zod reports. And because SignupValues is inferred from signupSchema rather than hand-written separately, the validation rules and the TypeScript type can never silently drift apart.

5. Accessible Error Messaging

An error message that's only a color change (red border, no text) fails anyone using a screen reader, and a text error not programmatically linked to its input means a screen reader user hears the error but has no way to know which field it belongs to. Two ARIA attributes fix this cheaply:

accessible field
<label htmlFor="email">Email</label>
<input
  id="email"
  {...register('email')}
  aria-invalid={errors.email ? 'true' : 'false'}
  aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
  <span id="email-error" role="alert">
    {errors.email.message}
  </span>
)}

aria-invalid announces the field's validity state itself. aria-describedby points at the error message's id, so a screen reader reads the error as part of describing that specific input — not as an unrelated, floating piece of text. role="alert" makes newly appearing errors announced immediately, without the user needing to navigate to find them.

Always pair a <label> with its input

htmlFor matching the input's id is what makes clicking a label focus its input, and is the very first thing a screen reader announces on that field. This will come up again as a broader topic in Week 23's accessibility module.

6. useId: Unique IDs for Reusable Field Components

Section 5's id="email" works fine when a form appears exactly once on a page. It quietly breaks the moment you extract a reusable FormField component and render two of them — two hardcoded, identical ids mean the browser silently uses only the first one for label association, and aria-describedby starts pointing at the wrong field's error. useId() generates a unique, stable ID per component instance so this can't happen, no matter how many times the component is rendered.

FormField.tsx — reusable, collision-proof
import { useId } from 'react';

interface FormFieldProps {
  label: string;
  error?: string;
  inputProps: React.InputHTMLAttributes<HTMLInputElement>;
}

function FormField({ label, error, inputProps }: FormFieldProps) {
  const id = useId();
  const errorId = `${id}-error`;

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        aria-invalid={error ? 'true' : 'false'}
        aria-describedby={error ? errorId : undefined}
        {...inputProps}
      />
      {error && <span id={errorId} role="alert">{error}</span>}
    </div>
  );
}

Render two <FormField>s on the same page and each gets its own distinct id (React generates something like :r0: and :r1:), automatically kept in sync between the <label>, the input, and the error span within each instance. Reach for useId specifically for this — never for a list's key (that should come from your data, as in Week 1) and never as a general-purpose unique-value generator.

Why not just Math.random() or a module-level counter?

Both produce a different value on the server than on the client during server-rendering (Week 18 onward) — the server generates one ID while pre-rendering the HTML, the client generates a different one while hydrating, and React throws a hydration-mismatch error because the two don't match. useId is specifically designed to produce the identical, deterministic value on both sides.

7. Hands-on Exercise

Hands-on

Build an accessible signup form with cross-field validation, then make it reusable

Combine React Hook Form, Zod, and accessible error markup into one real form — then prove the accessibility wiring survives being used twice on the same page.

Part 1 — The form:

  1. Install react-hook-form, zod and @hookform/resolvers in a Vite React project.
  2. Define a Zod schema with name (min 2 characters), email (valid email), password (min 8 characters), and confirmPassword.
  3. Use Zod's .refine() on the whole object to add a cross-field rule: confirmPassword must equal password, with a custom error message attached to the confirmPassword field specifically.
  4. Build the form with useForm + zodResolver, a <label> for every field, and aria-invalid/aria-describedby/role="alert" wired up correctly for every error.
  5. Disable the submit button while isSubmitting is true, and show a success message after a simulated 1-second setTimeout "submission."
Hint

Zod's .refine((data) => data.password === data.confirmPassword, { message: '...', path: ['confirmPassword'] }) is what attaches a whole-object validation error to one specific field's errors entry — without path, React Hook Form won't know which input to show it under.

Part 2 — Extract a reusable field, prove useId matters:

  1. Extract the label/input/error markup for each field into the FormField component from Section 6, using useId internally rather than any hardcoded id.
  2. Rewrite the signup form's four fields to use <FormField>, passing each field's register(...) result through as inputProps.
  3. Render the entire SignupForm twice on the same page, stacked vertically, each with its own useForm instance.
  4. Using your browser's DevTools, inspect the rendered HTML and confirm every id/aria-describedby pair is unique per instance, and that triggering an error in the second form's email field announces correctly without touching the first form's DOM nodes.
  5. Now break it on purpose: temporarily hardcode id="email" on both forms' email fields instead of using useId, and observe in DevTools that the browser now renders two elements sharing one id — confirm which one actually receives focus when you click the second form's label, and put the useId version back afterward.
Hint

Call useId() once per FormField instance, not once per SignupForm — if you hoist a single ID up and try to derive per-field IDs from it with string concatenation like `${id}-email`, you'll need to pass that base ID down as a prop, which defeats the point of a self-contained reusable component. Let each FormField call the hook itself.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

In a controlled input, what's actually the "source of truth" for what's displayed?

React state. The input's value prop is always driven from state, and onChange updates that state on every keystroke — the DOM element itself never independently "owns" its displayed value the way an uncontrolled input does.

Q2

How does React Hook Form track input values without triggering a re-render on every keystroke?

It uses uncontrolled inputs internally — register() attaches a ref and DOM event listeners directly to each input rather than driving its value from React state. The DOM manages the actual typed value; React Hook Form only reads it via the ref when it needs to (on submit, or on validation), which is why typing doesn't cause React re-renders the way a fully controlled form does.

Q3

Why use z.infer<typeof schema> instead of writing a separate TypeScript interface for the same form?

A hand-written interface and a validation schema describing the same data can silently drift apart — you add a field to one and forget the other, and TypeScript won't catch it. Inferring the type directly from the schema makes the schema the single source of truth: the type updates automatically whenever the schema changes, and the two can never disagree.

Q4

What does aria-describedby do that a visually adjacent error message alone doesn't?

Visual proximity means nothing to a screen reader, which announces elements based on the accessibility tree, not layout. aria-describedby explicitly links the input to the error's id, so assistive technology announces the error as part of that specific field's description — without it, a sighted user sees the connection but a screen reader user may not.