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 13 Week 5 of 20 ~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 18's accessibility module.

6. Hands-on Exercise

Hands-on

Build an accessible signup form with cross-field validation

Combine React Hook Form, Zod, and accessible error markup into one real form.

Requirements:

  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.

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