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:
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:
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:
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.
npm install 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.
npm install zod @hookform/resolvers
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>;
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:
<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.
<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.
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.
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
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:
- Install
react-hook-form,zodand@hookform/resolversin a Vite React project. - Define a Zod schema with
name(min 2 characters),email(valid email),password(min 8 characters), andconfirmPassword. - Use Zod's
.refine()on the whole object to add a cross-field rule:confirmPasswordmust equalpassword, with a custom error message attached to theconfirmPasswordfield specifically. - Build the form with
useForm+zodResolver, a<label>for every field, andaria-invalid/aria-describedby/role="alert"wired up correctly for every error. - Disable the submit button while
isSubmittingis true, and show a success message after a simulated 1-secondsetTimeout"submission."
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:
- Extract the label/input/error markup for each field into the
FormFieldcomponent from Section 6, usinguseIdinternally rather than any hardcodedid. - Rewrite the signup form's four fields to use
<FormField>, passing each field'sregister(...)result through asinputProps. - Render the entire
SignupFormtwice on the same page, stacked vertically, each with its ownuseForminstance. - Using your browser's DevTools, inspect the rendered HTML and confirm every
id/aria-describedbypair 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. - Now break it on purpose: temporarily hardcode
id="email"on both forms' email fields instead of usinguseId, and observe in DevTools that the browser now renders two elements sharing oneid— confirm which one actually receives focus when you click the second form's label, and put theuseIdversion back afterward.
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?
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?
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?
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?
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.