UI Library Integration
Most real-world React apps don't use plain HTML inputs. They use a UI library — Material UI, Ant Design, Chakra UI, Headless UI, or something custom. These libraries ship styled, accessible components, but they're almost always controlled. They don't expose a ref to an underlying <input> element, so register can't attach to them directly. That's where Controller comes in.
The Problem with register
The register function works by attaching a ref to a native DOM element. It reads and writes the value through that ref. When a UI library wraps the input in its own component and doesn't forward the ref, register has nothing to attach to:
// This won't work — TextInput is a controlled component, not a native <input>
<TextInput label="Email" {...register("email")} />
The component might accept value and onChange props, but register returns ref, onChange, onBlur, and name — the shapes don't match. You need an adapter that bridges React Hook Form's internal state with the component's controlled API.
Controller Basics
Controller is that adapter. It manages the field value internally and exposes it through a render function:
import { useForm, Controller } from "react-hook-form";
<Controller
name="email"
control={control}
rules={{ required: "Email is required" }}
render={({ field, fieldState }) => (
<TextInput
label="Email"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>
The field object gives you value, onChange, onBlur, name, and ref. The fieldState object gives you error, isDirty, and isTouched. You map these onto whatever props your UI component expects.
Wrapping a Select Component
Selects and dropdowns follow the same pattern. The component expects a value and calls onChange with the selected option:
<Controller
name="role"
control={control}
rules={{ required: "Please select a role" }}
render={({ field, fieldState }) => (
<SelectDropdown
label="Role"
options={["Admin", "Editor", "Viewer"]}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>
Some select components pass the raw event to onChange, others pass the value directly. If yours passes an event, use (e) => field.onChange(e.target.value) instead of field.onChange directly.
Checkboxes and Switches
Boolean controls work the same way, but the value is a boolean instead of a string:
<Controller
name="notifications"
control={control}
render={({ field }) => (
<Switch
label="Enable notifications"
checked={field.value}
onChange={field.onChange}
/>
)}
/>
Set a defaultValues entry for boolean fields — notifications: false — so the initial render has the right state. Without it, the value starts as undefined, which can cause the component to flip between uncontrolled and controlled behavior.
The Repetition Problem
By now you can probably see the pattern: every field needs a Controller, a render function, the same field.value / field.onChange / field.onBlur wiring, a label, and error display. In a form with ten fields, that's a lot of identical boilerplate:
{/* This block repeats for every single field */}
<Controller
name="firstName"
control={control}
rules={{ required: "Required" }}
render={({ field, fieldState }) => (
<div>
<label>{/* some label */}</label>
<TextInput value={field.value} onChange={field.onChange} onBlur={field.onBlur} />
{fieldState.error && <p style={{ color: "red" }}>{fieldState.error.message}</p>}
</div>
)}
/>
You're copying this structure over and over, changing only the field name, label, rules, and which component to render. That's a sign you need an abstraction.
Building a FormField Wrapper
The fix is a reusable FormField component that handles the Controller plumbing, label rendering, and error display in one place:
import { Controller, Control, FieldValues, Path, RegisterOptions } from "react-hook-form";
import { ReactElement } from "react";
type FormFieldProps<T extends FieldValues> = {
name: Path<T>;
control: Control<T>;
label: string;
rules?: RegisterOptions;
render: (field: {
value: any;
onChange: (...args: any[]) => void;
onBlur: () => void;
}) => ReactElement;
};
function FormField<T extends FieldValues>({
name,
control,
label,
rules,
render,
}: FormFieldProps<T>) {
return (
<Controller
name={name}
control={control}
rules={rules}
render={({ field, fieldState }) => (
<div style={{ marginBottom: "1rem" }}>
<label style={{ display: "block", marginBottom: "0.25rem" }}>
{label}
</label>
{render({
value: field.value,
onChange: field.onChange,
onBlur: field.onBlur,
})}
{fieldState.error && (
<p style={{ color: "red", marginTop: "0.25rem" }}>
{fieldState.error.message}
</p>
)}
</div>
)}
/>
);
}
Now each field in your form is a few lines instead of a dozen. The wrapper owns the layout, the label, and the error display. The render prop gives you just the three things you actually need to vary: value, onChange, and onBlur.
Complete Example
Here's a settings form using FormField with four different field types:
import { useForm } from "react-hook-form";
type SettingsData = {
displayName: string;
email: string;
role: string;
notifications: boolean;
};
function SettingsForm() {
const { control, handleSubmit } = useForm<SettingsData>({
defaultValues: {
displayName: "",
email: "",
role: "",
notifications: false,
},
});
const onSubmit = (data: SettingsData) => {
console.log("Settings saved:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FormField
name="displayName"
control={control}
label="Display Name"
rules={{ required: "Display name is required", minLength: { value: 2, message: "Too short" } }}
render={({ value, onChange, onBlur }) => (
<TextInput value={value} onChange={onChange} onBlur={onBlur} />
)}
/>
<FormField
name="email"
control={control}
label="Email Address"
rules={{ required: "Email is required" }}
render={({ value, onChange, onBlur }) => (
<TextInput value={value} onChange={onChange} onBlur={onBlur} />
)}
/>
<FormField
name="role"
control={control}
label="Role"
rules={{ required: "Please select a role" }}
render={({ value, onChange, onBlur }) => (
<SelectDropdown
options={["Admin", "Editor", "Viewer"]}
value={value}
onChange={onChange}
onBlur={onBlur}
/>
)}
/>
<FormField
name="notifications"
control={control}
label="Email Notifications"
render={({ value, onChange }) => (
<Switch checked={value} onChange={onChange} />
)}
/>
<button type="submit">Save Settings</button>
</form>
);
}
export default SettingsForm;
Compare this to writing four separate Controller blocks with their own label and error markup. The form reads top to bottom — name, label, rules, component — with no repeated boilerplate. Adding a new field means adding one FormField call, not copying a 15-line block and tweaking the parts that differ.
When You Can Skip Controller
Not every UI library component needs Controller. If a component forwards its ref to an underlying <input>, <select>, or <textarea> element, register works directly:
// If TextInput forwards ref to a native <input>, this works
<TextInput label="Name" {...register("name", { required: "Required" })} />
Many modern UI libraries — including recent versions of Material UI and Chakra UI — do forward refs. Check the library's docs or try it: if register attaches correctly and the value syncs on submit, you're good. Using register directly is better for performance because it avoids the extra re-renders that Controller's controlled component pattern creates.
The pattern for integrating UI libraries comes down to one decision: does the component forward a ref to a native element? If yes, use register directly. If no, use Controller. Either way, wrapping the repetitive parts — Controller, label, error display — into a FormField component keeps your forms clean and consistent. Build the wrapper once, and every form in your app gets the same structure with minimal code per field.