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 18's accessibility module.
6. Hands-on Exercise
Build an accessible signup form with cross-field validation
Combine React Hook Form, Zod, and accessible error markup into one real form.
Requirements:
- 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.
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?
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.