In the previous lesson you built a concert form using useReducer. It worked — but look at what you had to write: a reducer function, five or six action types, a handleFieldChange function that dispatched on every keystroke, isSubmitting state, error state, and a submit handler that coordinated all of it. That's a lot of infrastructure for what is, at the end of the day, a form.
React Hook Form is a library that handles most of that infrastructure for you. It gives you a useForm hook that manages field values, validation, error messages, and submission — without requiring you to write a single onChange handler or manually track what the user has typed. One of the key concepts in React Hook Form is to register your component into the hook. This makes its value available for both form validation and submission.
The library is tiny (around 9kb gzipped), has zero dependencies, and is one of the most downloaded packages in the React ecosystem. If you're building forms professionally, you will encounter it.
npm install react-hook-form
Before diving into the API, it's worth understanding exactly what pain React Hook Form eliminates. Here's a simplified version of what manual form state looks like at scale:
// What you wrote with useReducer
const [state, dispatch] = useReducer(concertFormReducer, initialState);
function handleFieldChange(e) {
dispatch({
type: 'UPDATE_FIELD',
payload: { field: e.target.name, value: e.target.value },
});
}
// Every input needs value + onChange
<input
name="artist"
value={state.artist}
onChange={handleFieldChange}
/>
This is called a controlled input — React owns the value, and every keystroke causes a state update and a re-render. For one or two fields, that's fine. For a form with eight fields and complex cross-field validation, you're triggering a lot of unnecessary re-renders and writing a lot of repetitive code.
React Hook Form flips the model. It uses uncontrolled inputs by default, reading values from the DOM directly via refs rather than keeping them in React state. This means the component doesn't re-render on every keystroke — only when validation errors change or the form is submitted. The library minimises the number of re-renders, minimises validate computation, and provides faster mounting.
Everything starts with useForm. Call it at the top of your component and destructure what you need:
import { useForm } from 'react-hook-form';
function AddConcertForm() {
const {
register, // connects inputs to the form
handleSubmit, // wraps your submit handler with validation
formState: { errors }, // validation error messages
} = useForm();
}
These three are the core of React Hook Form. There are more things you can pull out of useForm (like reset, watch, setValue, and getValues), but you can go a long way with just these three.
You can also set default values for all fields upfront, which is cleaner than setting defaultValue on each individual input:
const { register, handleSubmit, formState: { errors } } = useForm({
defaultValues: {
artist: '',
venue: '',
date: '',
ticketPrice: '',
},
});
By invoking the register function and supplying an input's name, you connect that input to the form. The spread syntax does the work:
<input {...register('artist')} placeholder="Artist name" />
What register('artist') returns is an object containing onChange, onBlur, name, and ref — all the props a native input needs. Spreading it onto the input wires everything up automatically. Each field is required to have a name as a key for the registration process.
Here's a full basic form to make the pattern concrete:
import { useForm } from 'react-hook-form';
function AddConcertForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
defaultValues: {
artist: '',
venue: '',
date: '',
ticketPrice: '',
},
});
function onSubmit(data) {
// data is a plain object: { artist: 'Radiohead', venue: '...', ... }
// No manual state collection needed — React Hook Form assembled it
console.log(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="artist">Artist</label>
<input id="artist" {...register('artist')} />
<label htmlFor="venue">Venue</label>
<input id="venue" {...register('venue')} />
<label htmlFor="date">Date</label>
<input id="date" type="date" {...register('date')} />
<label htmlFor="ticketPrice">Ticket Price</label>
<input id="ticketPrice" type="number" {...register('ticketPrice')} />
<button type="submit">Add Concert</button>
</form>
);
}
Notice what isn't there: no useState, no useReducer, no onChange handlers, no value props. The form works — and when submitted, onSubmit receives the collected values as a plain object.
The second argument to register is an options object where you define validation rules. These are closely aligned with native HTML validation attributes, so many of them will feel familiar:
<input
{...register('artist', {
required: 'Artist name is required',
minLength: {
value: 2,
message: 'Artist name must be at least 2 characters',
},
})}
/>
Passing a string instead of true to required (or as the message of any rule) means React Hook Form stores that string as the error message — you don't need a separate lookup to figure out what went wrong.
The main built-in validation rules:
{
required: 'This field is required',
minLength: { value: 2, message: 'Too short' },
maxLength: { value: 100, message: 'Too long' },
min: { value: 0, message: 'Must be a positive number' },
max: { value: 10000, message: 'Must be under £10,000' },
pattern: {
value: /^\d{4}-\d{2}-\d{2}$/,
message: 'Date must be in YYYY-MM-DD format',
},
// Custom validation function — return true if valid, a message string if not
validate: (value) => {
const date = new Date(value);
return date > new Date() || 'Concert date must be in the future';
},
}
The full list of validation rules is available in the register documentation.
<aside> ⚠️
Warning
By default, React Hook Form only validates on submit. If you want live validation as the user types or leaves a field, you can configure the mode option on useForm: useForm({ mode: 'onBlur' }) validates when a field loses focus, and useForm({ mode: 'onChange' }) validates on every keystroke. The tradeoff is more re-renders — use onBlur as a good default if you want faster feedback without the performance cost of onChange.
</aside>
The errors object from formState is populated automatically when validation fails. Each key corresponds to a field name, and each value has a message property containing the string you provided:
import { useForm } from 'react-hook-form';
function AddConcertForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
defaultValues: {
artist: '',
venue: '',
date: '',
ticketPrice: '',
},
});
async function onSubmit(data) {
try {
await saveConcert(data);
} catch (error) {
console.error(error);
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="artist">Artist</label>
<input
id="artist"
aria-invalid={errors.artist ? 'true' : 'false'}
{...register('artist', {
required: 'Artist name is required',
minLength: { value: 2, message: 'Name is too short' },
})}
/>
{/* errors.artist?.message is undefined when valid, so nothing renders */}
{errors.artist && (
<p role="alert">{errors.artist.message}</p>
)}
</div>
<div>
<label htmlFor="venue">Venue</label>
<input
id="venue"
aria-invalid={errors.venue ? 'true' : 'false'}
{...register('venue', {
required: 'Venue is required',
})}
/>
{errors.venue && (
<p role="alert">{errors.venue.message}</p>
)}
</div>
<div>
<label htmlFor="ticketPrice">Ticket Price (£)</label>
<input
id="ticketPrice"
type="number"
aria-invalid={errors.ticketPrice ? 'true' : 'false'}
{...register('ticketPrice', {
required: 'Ticket price is required',
min: { value: 0, message: 'Price cannot be negative' },
max: { value: 10000, message: 'Price seems too high' },
})}
/>
{errors.ticketPrice && (
<p role="alert">{errors.ticketPrice.message}</p>
)}
</div>
<div>
<label htmlFor="date">Date</label>
<input
id="date"
type="date"
aria-invalid={errors.date ? 'true' : 'false'}
{...register('date', {
required: 'Date is required',
validate: (value) =>
new Date(value) > new Date() || 'Concert must be in the future',
})}
/>
{errors.date && (
<p role="alert">{errors.date.message}</p>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Add Concert'}
</button>
</form>
);
}
A few things worth pointing out:
aria-invalid communicates to screen readers that an input has an error. The React Hook Form accessibility docs recommend this pattern — after the improvement, a screen reader will announce something like: "Artist, edit, invalid entry, Artist name is required."role="alert" on the error paragraph causes screen readers to announce the message automatically when it appears.isSubmitting from formState is true while your async onSubmit function is running. React Hook Form tracks this for you — no isLoading state needed.<aside> ⌨️
Hands On
Add validation to the form you built in the previous exercise. Each field should have a required rule. The ticketPrice field should have min and max rules. The date field should use a custom validate function that rejects past dates. Try submitting the form empty and confirm that all four error messages appear. Then fix each field one by one and observe how the error messages disappear as each field becomes valid.
</aside>
Now you have both versions — the useReducer-based AddConcertForm from Lesson 1, and the React Hook Form version above. Here's a direct comparison of just the submission handler, which shows where the difference is most visible:
// With useReducer — you manage every state transition
async function handleSubmit(e) {
e.preventDefault();
dispatch({ type: 'SUBMIT_START' }); // set isSubmitting manually
dispatch({ type: 'CLEAR_ERROR' }); // clear previous errors manually
try {
await saveConcert(state);
dispatch({ type: 'SUBMIT_SUCCESS' }); // reset form manually
} catch (err) {
dispatch({ type: 'SUBMIT_ERROR', payload: err.message });
}
}
// With React Hook Form — the library handles submission state
async function onSubmit(data) {
// isSubmitting is true automatically while this runs
// data is already assembled — no state.artist, state.venue, etc.
try {
await saveConcert(data);
reset(); // reset() is from useForm — clears all fields
} catch (err) {
setError('root', { message: err.message }); // setError is from useForm
}
}
The useReducer version is explicit and readable — every transition is named and traceable. React Hook Form is more concise, but the submission flow is implicit. Neither approach is wrong. The point of building the useReducer version first was to understand what the library is doing for you — and now you do.
<aside> 💡
Info
setError and reset are additional methods you can destructure from useForm. setError('root', { message: '...' }) sets a form-level error (not tied to a specific field) — useful for API errors that apply to the whole submission. reset() returns all fields to their defaultValues. See the full API reference for the complete list.
</aside>
React Hook Form is excellent for forms. It is not a general state management tool. If your component has state that isn't about capturing user input — a list of concerts being fetched from an API, a filter panel, a modal's open/closed state — useReducer and useState are still the right tools.
The mental model is:
useState or useReduceruseReducerThese tools are designed to complement each other, not compete.
defaultValues to useForm. This ensures fields are controlled from the start and prevents React warnings about switching between controlled and uncontrolled inputs.aria-invalid with role="alert" on error messages for accessible validation feedback.mode: 'onBlur' if you want validation feedback before form submission, without the overhead of validating on every keystroke.isSubmitting from formState to disable the submit button during async operations — it's already tracked for you.setError('root', ...) for API errors that apply to the whole form, not a specific field.<aside> 🎉
Congratulations
You've completed all four lessons in Week 9. You started with useReducer for structured local state, combined it with Context for sharing that state across your tree, learned how Error Boundaries contain failures gracefully, and finished by replacing a hand-rolled form with React Hook Form. These four patterns — individually and together — are the foundation of how professional React applications manage state and handle complexity. You now have the full toolkit.
</aside>
Videos
useForm, validation, error messages, and introduces Zod integration — good to revisit later in the curriculumReading
register, handleSubmit, error display, and schema validation in one pagerequired, min, max, pattern, and validate; bookmark this while building formsThe HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with ❤️ by the HackYourFuture community · Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.