Lesson 11

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.