| | |

React Hook Form 11 📋 Controlled Components — The Controller wrapper for UI libraries

⏱️ Estimated read: 16 min | |

The register function works because the native input exposes a DOM ref that React Hook Form can read. A third-party component—a Material UI TextField, a React Select, a date picker, a masked input—does not necessarily expose that ref. It exposes a value prop and an onChange callback instead. The Controller component is the bridge: it renders your component, receives the field object from the library, and passes the value, the onChange, the onBlur, the name, and the ref into the component’s props . The library still owns the value; the component renders it. The difference between the two integration paths is not style. It is the mechanism by which the library learns that the value changed, and the mechanism determines whether the form sees the input at all.

The distinction between Controller and useController is the distinction between a component and a hook. The Controller is a component with a render prop, and it is the convenient form when the controlled component is used once. The useController is the hook form of the same contract, and it is the mechanism for building a reusable controlled input that encapsulates the connection . Both return the same field and fieldState objects, and both connect to the same control object from useForm. The choice is the reuse: a one-off integration uses the Controller, and a design-system component that is used across the forms uses the useController.

The Controller is not a fallback for the components that lack the ref. It is the recommended approach for the third-party components even when the ref is available, because the Controller isolates the re-render to the wrapped component. A form with fifty fields re-renders the whole form on every keystroke when the values are in the form-level state and the component reads them during the render. The Controller confines the re-render to the wrapped component: the field.value is read inside the Controller‘s render, and the parent form does not re-render when the field changes.

Key point: The Controller is the wrapper for the controlled components that do not expose a native ref. The control prop from useForm is required, and the render function receives the field object with value, onChange, onBlur, name, and ref. The useController hook is the same contract for a reusable component. The defaultValue must be set at the field level or in useForm‘s defaultValues, never undefined, or the input starts uncontrolled and React warns.


Why the Controller exists

The ref problem. The register function connects a native input through the ref, and the library reads the DOM value when it needs it. A Material UI TextField does not expose the ref directly; it wraps the native input and exposes the value and the onChange props instead . The register on such a component spreads a ref that the component ignores, and the form never receives the value. The Controller is the mechanism that avoids the ref entirely: it passes the value and the onChange into the component’s props, and the component’s own state becomes the form’s state.

The isolated re-render problem. A form with fifty fields re-renders the whole form on every keystroke when the values are in the form-level state and the component reads them during the render. The Controller confines the re-render to the wrapped component: the field.value is read inside the Controller‘s render, and the parent form does not re-render when the field changes . The isolation is the performance mechanism, and it is the reason the Controller is the recommended approach even when the component does expose a ref.

The field-state problem. The Controller‘s render function receives not just the field object but also the fieldState, which contains the invalid, the isTouched, the isDirty, the isValidating, and the error for that specific field . The field-level state is the mechanism that lets the component display its own error without reading the form-wide errors object. The component that owns the field owns its error display.

The default-value problem. The Controller‘s defaultValue and the useForm‘s defaultValues are the two sources of the initial value, and the interaction is the source of the most common bug. The documentation states that undefined cannot be the default value, and that calling onChange with undefined is invalid . If field.value is undefined on the first render, the input starts uncontrolled and React warns when it later becomes controlled. The defaultValue must be set explicitly, or the useForm‘s defaultValues must cover the field.

The reusable-component problem. A design system has a TextField that is used in twenty forms. Wrapping it in a Controller in every form is the duplication. The useController hook is the mechanism that moves the connection into the component itself, so the form passes the control and the name, and the component handles the field and the fieldState internally . The reusability is the reason the hook exists.


a. The Controller component and the render contract

The Controller requires the name, the control, and the render function. The render function receives the field and the fieldState, and it returns the component with the props spread.

// The Controller with a Material UI TextField
import { useForm, Controller } from "react-hook-form";
import { TextField } from "@mui/material";

type LoginFormValues = {
  email: string;
};

function LoginForm() {
  const { control, handleSubmit } = useForm<LoginFormValues>({
    // The defaultValues is the single source of truth.
    // The Controller's defaultValue is the alternative.
    defaultValues: { email: "" },
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <Controller
        name="email"
        control={control}
        rules={{ required: "The email is required." }}
        render={({ field, fieldState }) => (
          <TextField
            // The field.value is the current value.
            value={field.value}
            // The field.onChange sends the new value to the form.
            onChange={field.onChange}
            // The field.onBlur marks the field as touched.
            onBlur={field.onBlur}
            // The field.name is the field's path.
            name={field.name}
            // The inputRef is the ref for the focus on error.
            inputRef={field.ref}
            // The fieldState.invalid is the field-level invalid state.
            error={fieldState.invalid}
            // The fieldState.error.message is the field-level error.
            helperText={fieldState.error?.message}
            label="Email"
          />
        )}
      />
      <button type="submit">Log in</button>
    </form>
  );
}

The field.value is the controlled value, and the field.onChange is the controlled change handler. The inputRef is the MUI-specific prop that forwards the ref to the native input, and the field.ref is the mechanism that lets the library focus the field when the validation fails . The fieldState.invalid and the fieldState.error are the field-level state, and the helperText is the MUI prop that displays the error.

The rules prop on the Controller accepts the same validation rules as the register options: the required, the minLength, the pattern, and the validate. The rules run on the field’s value, and the fieldState.error holds the failure . The validation is the mechanism that keeps the Controller’s behavior consistent with the register’s.

// The Controller with the validation rules
<Controller
  name="age"
  control={control}
  rules={{
    required: "The age is required.",
    min: { value: 18, message: "The age must be at least 18." },
  }}
  render={({ field, fieldState }) => (
    <TextField
      {...field}
      type="number"
      error={fieldState.invalid}
      helperText={fieldState.error?.message}
    />
  )}
/>

The {...field} spread is the shorthand that passes the value, the onChange, the onBlur, the name, and the ref at once. The spread works for the components that accept the standard prop names, and the explicit props are the mechanism for the components that use the different names.


b. The useController hook and the reusable component

The useController hook is the same contract as the Controller, but it is a hook instead of a component. It returns the field and the fieldState, and it accepts the name, the control, the rules, and the defaultValue.

// The reusable controlled input with useController
import { useController, type Control, type FieldPath, type FieldValues } from "react-hook-form";
import { TextField } from "@mui/material";

type TextInputProps<T extends FieldValues> = {
  control: Control<T>;
  name: FieldPath<T>;
  label: string;
};

function TextInput<T extends FieldValues>({ control, name, label }: TextInputProps<T>) {
  // The useController connects the component to the form.
  // The field and the fieldState are the same as the Controller's.
  const { field, fieldState } = useController({
    control,
    name,
    rules: { required: `${label} is required.` },
  });

  return (
    <TextField
      label={label}
      value={field.value}
      onChange={field.onChange}
      onBlur={field.onBlur}
      name={field.name}
      inputRef={field.ref}
      error={fieldState.invalid}
      helperText={fieldState.error?.message}
    />
  );
}

The generic type <T extends FieldValues> is the mechanism that makes the component reusable across the forms. The Control<T> and the FieldPath<T> are the types that tie the component to the specific form’s shape, and the name is checked against the form’s fields . The generic is the TypeScript mechanism, and the type checking is the reason the reusable component does not accept the wrong field name.

// The form that uses the reusable TextInput
function LoginForm() {
  const { control, handleSubmit } = useForm<LoginFormValues>({
    defaultValues: { email: "" },
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      {/* The TextInput handles the Controller internally. */}
      <TextInput control={control} name="email" label="Email" />
      <button type="submit">Log in</button>
    </form>
  );
}

The form does not wrap the TextInput in a Controller. The TextInput is the controlled component, and the useController is the connection. The form passes the control and the name, and the component does the rest . The reusability is the mechanism that removes the duplication from the forms.

The FormProvider is the alternative to the control prop. When the form is wrapped in the <FormProvider {...methods}>, the useController reads the control from the context, and the component does not need the prop . The context is the mechanism that keeps the component’s API clean, and the prop is the mechanism that keeps the dependency explicit.

// The FormProvider with the useController
import { FormProvider, useForm } from "react-hook-form";

function LoginForm() {
  const methods = useForm<LoginFormValues>({
    defaultValues: { email: "" },
  });

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit((data) => console.log(data))}>
        {/* The TextInput reads the control from the context. */}
        <TextInput name="email" label="Email" />
      </form>
    </FormProvider>
  );
}

The FormProvider is the mechanism that passes the control to every descendant, and the useController without the control prop reads it from the context. The context is the convenience, and the prop is the explicitness. The choice is the project’s convention.


Complete Example Session

// ============================================
// PART 1: THE CONTROLLER WITH THE MUI TEXT FIELD
// ============================================

import { useForm, Controller } from "react-hook-form";
import { TextField } from "@mui/material";

function LoginForm() {
  const { control, handleSubmit } = useForm<LoginFormValues>({
    defaultValues: { email: "" },
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <Controller
        name="email"
        control={control}
        rules={{ required: "The email is required." }}
        render={({ field, fieldState }) => (
          <TextField
            {...field}
            error={fieldState.invalid}
            helperText={fieldState.error?.message}
            label="Email"
          />
        )}
      />
    </form>
  );
}

// ============================================
// PART 2: THE CONTROLLER WITH THE SELECT
// ============================================

import { Select, MenuItem } from "@mui/material";

<Controller
  name="department"
  control={control}
  rules={{ required: "The department is required." }}
  render={({ field, fieldState }) => (
    <Select {...field} error={fieldState.invalid}>
      <MenuItem value="engineering">Engineering</MenuItem>
      <MenuItem value="design">Design</MenuItem>
    </Select>
  )}
/>

// ============================================
// PART 3: THE CONTROLLER WITH THE DATE PICKER
// ============================================

<Controller
  name="startDate"
  control={control}
  render={({ field }) => (
    <DatePicker
      selected={field.value}
      onChange={field.onChange}
      onBlur={field.onBlur}
    />
  )}
/>

// The DatePicker uses the selected and the onChange instead of the value.
// The field object is the bridge.

// ============================================
// PART 4: THE USE CONTROLLER REUSABLE INPUT
// ============================================

function TextInput<T extends FieldValues>({ control, name, label }: TextInputProps<T>) {
  const { field, fieldState } = useController({ control, name });
  return (
    <TextField
      label={label}
      value={field.value}
      onChange={field.onChange}
      onBlur={field.onBlur}
      name={field.name}
      inputRef={field.ref}
      error={fieldState.invalid}
      helperText={fieldState.error?.message}
    />
  );
}

// ============================================
// PART 5: THE FORM WITH THE REUSABLE INPUT
// ============================================

function ProfileForm() {
  const { control, handleSubmit } = useForm<ProfileFormValues>({
    defaultValues: { name: "", email: "" },
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <TextInput control={control} name="name" label="Name" />
      <TextInput control={control} name="email" label="Email" />
    </form>
  );
}

// ============================================
// PART 6: THE FORM PROVIDER ALTERNATIVE
// ============================================

<FormProvider {...methods}>
  <form onSubmit={methods.handleSubmit(onSubmit)}>
    <TextInput name="name" label="Name" />
  </form>
</FormProvider>

// The useController reads the control from the context.

// ============================================
// PART 7: THE DEFAULT VALUE AT THE FIELD LEVEL
// ============================================

<Controller
  name="subscribe"
  control={control}
  defaultValue={false}
  render={({ field }) => <Checkbox {...field} />}
/>

// The defaultValue is the field-level fallback.
// The useForm's defaultValues is the alternative.

// ============================================
// PART 8: THE FIELD STATE
// ============================================

<Controller
  name="email"
  control={control}
  render={({ field, fieldState }) => (
    <TextField
      {...field}
      error={fieldState.invalid}
      helperText={fieldState.error?.message}
    />
  )}
/>

// The fieldState is the per-field state.
// The error is the field-level error.

// ============================================
// PART 9: THE EXACT PROP
// ============================================

<Controller
  name="users.0.name"
  control={control}
  exact={false}
  render={({ field }) => <input {...field} />}
/>

// The exact: false is the nested update subscription.

// ============================================
// PART 10: THE CHECKLIST
// ============================================

// [ ] The Controller has the name, the control, and the render.
// [ ] The field.value and the field.onChange are passed.
// [ ] The fieldState.error is displayed.
// [ ] The defaultValues is set in useForm or the Controller.
// [ ] The useController is used for the reusable component.
// [ ] The FormProvider is used for the context-based control.

The ten parts covered the Controller with the MUI TextField, the Controller with the Select, the Controller with the DatePicker, the useController reusable input, the form with the reusable input, the FormProvider alternative, the default value at the field level, the field state, the exact prop, and the checklist. The through-line is that the Controller is the bridge, and the field object is the contract.


Quick Reference

The Controller props

PropRequiredPurpose
nameYesThe field path
controlYesThe control from useForm
renderYesThe function that returns the component
rulesNoThe validation rules
defaultValueNoThe field-level fallback
shouldUnregisterNoThe unmount behavior

The field object

PropertyPurpose
valueThe current value
onChangeThe change handler
onBlurThe blur handler
nameThe field name
refThe ref for the focus on error
disabledThe disabled state

The fieldState object

PropertyPurpose
invalidThe field is invalid
isTouchedThe field is blurred
isDirtyThe field is modified
isValidatingThe field is validating
errorThe field-level error

The Controller vs useController

AspectControlleruseController
The formThe componentThe hook
The reuseThe one-offThe reusable component
The renderThe render propThe function body

Best Practices

✅ Do This:

// Use the Controller for the one-off integration
<Controller name="email" control={control} render={({ field }) => <TextField {...field} />} /> // ✅

// Use the useController for the reusable component
const { field, fieldState } = useController({ control, name });   // ✅

// Set the defaultValues to avoid the uncontrolled warning
useForm({ defaultValues: { email: "" } });                        // ✅

❌ Don’t Do This:

// Use the register on the controlled component
<TextField {...register("email")} /> // the ref is not forwarded    // ❌

// Leave the defaultValue undefined
<Controller name="email" control={control} /> // the uncontrolled warning // ❌

Common Pitfalls

PitfallWhy It HappensFix
The input starts uncontrolledThe defaultValue is undefinedSet the defaultValues or the defaultValue
The ref is not forwardedThe component does not forward the inputRefUse the inputRef prop or the field.ref
The error is not displayedThe fieldState.error is not readPass the fieldState.error.message
The reusable component is not type-safeThe control is typed as the genericUse the Control<T> and the FieldPath<T>

Real-World Examples

1. The Controller with the MUI TextField

<Controller name="email" control={control} render={({ field }) => <TextField {...field} />} />

2. The Controller with the Select

<Controller name="dept" control={control} render={({ field }) => <Select {...field} />} />

3. The Controller with the DatePicker

<Controller name="date" control={control} render={({ field }) => <DatePicker selected={field.value} onChange={field.onChange} />} />

4. The useController reusable input

const { field } = useController({ control, name: "email" });

5. The generic type

function TextInput<T extends FieldValues>({ control, name }: Props<T>) {}

6. The FormProvider

<FormProvider {...methods}><TextInput name="email" /></FormProvider>

7. The field-level default

<Controller name="subscribe" control={control} defaultValue={false} render={...} />

8. The field state

render={({ field, fieldState }) => <TextField error={fieldState.invalid} />}

9. The validation rules

rules={{ required: "Required", min: 18 }}

10. The inputRef

inputRef={field.ref}

Visual

The Controller Bridge

┌──────────────────────────────────────────────────────────────┐
│  THE CONTROLLER IS THE BRIDGE                                │
│                                                              │
│  useForm                                                     │
│  ┌─────────────────────┐                                    │
│  │ control             │  The connection object             │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                    │
│  ┌─────────────────────┐                                    │
│  │ Controller          │  The wrapper                       │
│  │ name, control,      │                                    │
│  │ render              │                                    │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                    │
│  ┌─────────────────────┐                                    │
│  │ TextField           │  The controlled component          │
│  │ value, onChange,    │  The field object is the props     │
│  │ onBlur, ref         │                                    │
│  └─────────────────────┘                                    │
└──────────────────────────────────────────────────────────────┘

The Field Object

┌──────────────────────────────────────────────────────────────┐
│  THE FIELD OBJECT                                            │
│                                                              │
│  field                                                       │
│  ┌─────────────────────┐                                    │
│  │ value               │  The current value                 │
│  │ onChange            │  The change handler                │
│  │ onBlur              │  The blur handler                  │
│  │ name                │  The field path                    │
│  │ ref                 │  The focus ref                     │
│  └─────────────────────┘                                    │
│                                                              │
│  The fieldState                                              │
│  ┌─────────────────────┐                                    │
│  │ invalid             │  The field is invalid              │
│  │ isTouched           │  The field is blurred              │
│  │ error               │  The field-level error             │
│  └─────────────────────┘                                    │
└──────────────────────────────────────────────────────────────┘

The Reusable Component

┌──────────────────────────────────────────────────────────────┐
│  THE REUSABLE COMPONENT                                      │
│                                                              │
│  The form                                                    │
│  ┌─────────────────────┐                                    │
│  │ <TextInput          │  The control and the name          │
│  │   control={control} │                                    │
│  │   name="email" />   │                                    │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                    │
│  ┌─────────────────────┐                                    │
│  │ useController       │  The hook inside the component     │
│  │                     │  The field and the fieldState      │
│  └─────────────────────┘                                    │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
The ControllerThe component wrapper for the controlled components
The useControllerThe hook form for the reusable components
The controlThe object from useForm that connects the components
The fieldThe value, the onChange, the onBlur, the name, the ref
The fieldStateThe invalid, the isTouched, the error
The defaultValueThe field-level fallback, never undefined

Key takeaways:

  • The Controller is the bridge for the components that do not expose a native ref. The render function receives the field object with the value, the onChange, the onBlur, the name, and the ref, and the component receives the props .
  • The useController is the hook form for the reusable components. It returns the same field and fieldState, and the component encapsulates the connection. The form passes the control and the name, and the component does the rest .
  • The fieldState is the field-level state. The invalid and the error are the per-field state, and the component displays its own error without reading the form-wide errors .
  • The defaultValue must never be undefined. The input starts uncontrolled and React warns when it later becomes controlled. The defaultValues in useForm or the defaultValue on the Controller is the mechanism that avoids the warning .
  • The control is the connection object. It is passed to the Controller, the useController, the useWatch, the useFieldArray, and the FormProvider. The FormProvider is the context alternative that removes the prop from the components .

Remember: The React Hook Form Controller is the bridge for the controlled components. The useController is the hook form for the reusable components. The field object is the contract, and the fieldState is the field-level state. The defaultValue must be set, and the control is the connection. The Controller is the component, and the useController is the hook. The two are the same contract, and the choice is the reuse.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!