Week 9

useReducer

Context API

Error Boundaries

React Hook Form

Practice

Assignment

Front end Track

React Hook Form

What is React Hook Form?

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

The Problem With Manual Form State

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.

The useForm Hook

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: '',
  },
});

Registering Inputs

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.

Validation Rules

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>

Displaying Error Messages

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:

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

Replacing the useReducer Form

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>

What useReducer is Still Good For

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:

These tools are designed to complement each other, not compete.

Best Practices

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

Additional Resources

Videos

Reading


The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

CC BY-NC-SA 4.0 Icons

Built with ❤️ by the HackYourFuture community · Thank you, contributors

Found a mistake or have a suggestion? Let us know in the feedback form.