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 withregistercontrol— thecontrolobject returned byuseFormrender— 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 formfieldState—{ invalid, isTouched, isDirty, error }— validation and interaction state for this specific fieldformState— the same object you get fromuseForm'sformState, 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.