Lesson 10

Controller and Controlled Components

The Problem: Components That Don't Expose a Ref

React Hook Form's register function works by attaching a ref to the DOM element. That's what makes it fast — it reads values directly from the DOM instead of going through React state. But not every component gives you access to the underlying DOM element.

Third-party UI components like React Select, date pickers, and custom components from libraries like MUI or Ant Design manage their own internal state. They don't expose a ref to a native input. If you try to spread register onto them, the ref has nowhere to attach and RHF can't read or set the value.

import Select from "react-select";

// This won't work — React Select doesn't expose a ref to a native input
<Select {...register("category")} options={options} />

This is where Controller comes in.

The Controller Component

Controller acts as a bridge between React Hook Form and controlled components. It subscribes to the form state internally and passes the right props down to whatever component you render.

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

Controller takes three key props:

  • name — the field name, just like with register
  • control — the control object returned by useForm
  • render — a function that receives field data and returns your component

The render Prop

The render function receives an object with three properties:

  • field{ onChange, onBlur, value, name, ref } — these are the props your component needs to participate in the form
  • fieldState{ invalid, isTouched, isDirty, error } — validation and interaction state for this specific field
  • formState — the same object you get from useForm's formState, covering the entire form

Here's Controller wired up to a React Select component:

import { useForm, Controller } from "react-hook-form";
import Select from "react-select";

const options = [
  { value: "react", label: "React" },
  { value: "vue", label: "Vue" },
  { value: "angular", label: "Angular" },
];

function FrameworkForm() {
  const { control, handleSubmit } = useForm({
    defaultValues: { framework: null },
  });

  const onSubmit = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label>Framework</label>
      <Controller
        name="framework"
        control={control}
        rules={{ required: "Please select a framework" }}
        render={({ field, fieldState }) => (
          <div>
            <Select {...field} options={options} />
            {fieldState.error && (
              <p style={{ color: "red" }}>{fieldState.error.message}</p>
            )}
          </div>
        )}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

You can spread field directly onto React Select because it accepts onChange, onBlur, value, name, and ref as props. For components with a different prop API, pass them individually: onChange={field.onChange}, value={field.value}, and so on.

You can also pass validation rules via the rules prop on Controller, just like the second argument to register. Or if you're using a Zod resolver, skip rules entirely — the schema handles it.

useController: The Hook Variant

useController does the same thing as Controller but as a hook. This is useful when you're building reusable wrapper components and don't want the render-prop nesting.

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

function ControlledSelect({ name, control, options, label }) {
  const { field, fieldState } = useController({
    name,
    control,
    rules: { required: `${label} is required` },
  });

  return (
    <div>
      <label>{label}</label>
      <Select {...field} options={options} />
      {fieldState.error && (
        <p style={{ color: "red" }}>{fieldState.error.message}</p>
      )}
    </div>
  );
}

Now you can use <ControlledSelect> in any form without repeating the Controller boilerplate:

<ControlledSelect
  name="framework"
  control={control}
  label="Framework"
  options={frameworkOptions}
/>

The hook returns the same field and fieldState objects. It also returns formState if you need it. The difference is purely ergonomic — you're in a component with hooks instead of a render prop.

When NOT to Use Controller

If the component is a native HTML element — <input>, <select>, <textarea> — use register. Always. Controller wraps the field in a controlled component pattern, which means every value change triggers a React re-render for that field. With register, RHF reads from the DOM via refs and avoids re-renders entirely.

Controller exists to solve a specific problem: integrating components that don't expose a ref. If the component does expose a ref (native elements always do), register is the better choice because it's faster.

Complete Example: Mixing register and Controller

Most real forms have a mix of native inputs and third-party components. Here's how they work side by side:

import { useForm, Controller } from "react-hook-form";
import Select from "react-select";

const roleOptions = [
  { value: "frontend", label: "Frontend" },
  { value: "backend", label: "Backend" },
  { value: "fullstack", label: "Full Stack" },
];

function ProfileForm() {
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm({
    defaultValues: {
      name: "",
      email: "",
      role: null,
    },
  });

  const onSubmit = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" {...register("name", { required: "Name is required" })} />
        {errors.name && <p style={{ color: "red" }}>{errors.name.message}</p>}
      </div>

      <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>Role</label>
        <Controller
          name="role"
          control={control}
          rules={{ required: "Role is required" }}
          render={({ field, fieldState }) => (
            <div>
              <Select {...field} options={roleOptions} />
              {fieldState.error && (
                <p style={{ color: "red" }}>{fieldState.error.message}</p>
              )}
            </div>
          )}
        />
      </div>

      <button type="submit">Save Profile</button>
    </form>
  );
}

export default ProfileForm;

The name and email fields use register — they're native inputs, so refs work fine and there are no unnecessary re-renders. The role field uses Controller because React Select is a controlled component that doesn't expose a native input ref.

Controller bridges the gap between React Hook Form's ref-based approach and controlled components that manage their own state. Use it when a component doesn't expose a ref — React Select, date pickers, rich text editors, and similar third-party UI components. Use useController when you want the same behavior as a hook, which is ideal for building reusable wrapper components. For everything else — native HTML inputs — stick with register. It's faster and simpler.