Lesson 13

FormProvider and useFormContext

As forms grow, they naturally split into multiple components. A checkout form might have a billing section, a shipping section, and a payment section — each complex enough to warrant its own component. The moment you do that, you face a problem: how do those child components access register, control, and formState?

The Prop Drilling Problem

The naive approach is to pass everything down through props:

function CheckoutForm() {
  const { register, handleSubmit, control, formState } = useForm();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <PersonalInfo register={register} errors={formState.errors} />
      <ContactInfo register={register} errors={formState.errors} />
      <button type="submit">Submit</button>
    </form>
  );
}

function PersonalInfo({ register, errors }) {
  return (
    <div>
      <input {...register("firstName")} />
      {errors.firstName && <p>{errors.firstName.message}</p>}
      <input {...register("lastName")} />
      {errors.lastName && <p>{errors.lastName.message}</p>}
    </div>
  );
}

This works for two components, but it falls apart fast. Add a wrapper layout component between the form and the fields, and now that wrapper has to forward props it doesn't use. Add control for controlled components, watch for conditional fields, setValue for programmatic updates — each new need means threading another prop through every layer. It's fragile and cluttered.

FormProvider

React Hook Form solves this with FormProvider, a context provider that makes the entire form API available to any descendant component. You call useForm as usual, then spread the returned methods object into FormProvider:

import { useForm, FormProvider } from "react-hook-form";

function CheckoutForm() {
  const methods = useForm({
    defaultValues: {
      firstName: "",
      lastName: "",
      email: "",
      phone: "",
    },
  });

  const onSubmit = (data) => {
    console.log("Submitted:", data);
  };

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <PersonalInfo />
        <ContactInfo />
        <button type="submit">Submit</button>
      </form>
    </FormProvider>
  );
}

Notice the parent component no longer passes any props to the section components. FormProvider handles distribution through React context. You spread {...methods} so that every property from useFormregister, control, handleSubmit, formState, watch, setValue, and the rest — is available downstream.

useFormContext

Child components access the form API by calling useFormContext(). It returns the same object you would get from useForm — same properties, same methods, same types:

import { useFormContext } from "react-hook-form";

function PersonalInfo() {
  const {
    register,
    formState: { errors },
  } = useFormContext();

  return (
    <fieldset>
      <legend>Personal Information</legend>
      <div>
        <label htmlFor="firstName">First Name</label>
        <input id="firstName" {...register("firstName", { required: "First name is required" })} />
        {errors.firstName && <p style={{ color: "red" }}>{errors.firstName.message}</p>}
      </div>
      <div>
        <label htmlFor="lastName">Last Name</label>
        <input id="lastName" {...register("lastName", { required: "Last name is required" })} />
        {errors.lastName && <p style={{ color: "red" }}>{errors.lastName.message}</p>}
      </div>
    </fieldset>
  );
}

The ContactInfo component follows the same pattern — call useFormContext, destructure what you need, register your fields:

import { useFormContext } from "react-hook-form";

function ContactInfo() {
  const {
    register,
    formState: { errors },
  } = useFormContext();

  return (
    <fieldset>
      <legend>Contact Information</legend>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register("email", { required: "Email is required" })} />
        {errors.email && <p style={{ color: "red" }}>{errors.email.message}</p>}
      </div>
      <div>
        <label htmlFor="phone">Phone</label>
        <input id="phone" type="tel" {...register("phone", { required: "Phone is required" })} />
        {errors.phone && <p style={{ color: "red" }}>{errors.phone.message}</p>}
      </div>
    </fieldset>
  );
}

Both components register fields into the same form instance. When the user submits, handleSubmit collects firstName, lastName, email, and phone from a single unified form state — no prop wiring required. Each section component is self-contained and testable on its own (wrap it in a FormProvider in your test and it works).

When to Use It

FormProvider and useFormContext are the right tool when your form spans multiple components. Multi-section forms like the example above are the most common case. Multi-step forms — where each step is a separate component that renders different fields — are another natural fit. If you're building reusable form field components that need access to register or control, context saves you from requiring callers to pass those in every time.

When Not to Use It

If your entire form lives in a single component, skip the context. Just destructure register and formState directly from useForm and use them inline. Adding FormProvider to a form that doesn't need it adds indirection without benefit — you're wrapping a component in context just to read that context in the same component. Reach for it when prop drilling actually becomes a problem, not before.