Max Zavati

React Hook Form with Custom Components

Dec 9, 2024
React Hook Form with Custom Components

I’ve noticed that many people wonder how to connect more complex components to React Hook Form, beyond the basic input fields. This article will guide you through the process and make it straightforward!

I'm assuming you're already familiar with the basics of React Hook Form, as this article focuses on how to connect various custom components, such as select elements, checkboxes with multiple selections, and custom inputs. While I could include more examples, the principle remains the same for every type of component. In this article, I will demonstrate examples using Radix UI and React Select to quickly create ready-to-use components that adhere to best practices. However, the approach I'll show can also be applied to purely custom components.

So let's start with example by defining our form hook. This is a fully typed example, and we are using the Zod library for validation.

const formMethods = useForm<FormFieldsType>({
   resolver: zodResolver(formFieldsSchema),
   mode: 'onBlur',
   defaultValues: {
     firstName: '',
     lastName: '',
     gender: '',
     weeklySchedule: [],
     birthDate: '',
   },
 });

Custom Input

Let’s start with the most common and basic example, which is a custom input.

Custom input example
Custom input example

Here's a simple code example to make it work.

<Controller
  name="firstName"
  control={control}
  defaultValue=""
  render={({ field }) => (
    <Input
      label="First name*"
      id="first-name-input"
      onBlur={field.onBlur}
      value={field.value}
      placeholder="Your first name"
      onChange={(value) => {
        field.onChange(value);
        if (firstNameError) {
          clearErrors("firstName");
        }
      }}
      error={Boolean(firstNameError)}
      errorMessage={firstNameError?.message}
    />
   )}
 />
 
// Input component
const Input = ({
  label,
  id,
  value,
  onChange,
  onBlur,
  placeholder,
  error,
  errorMessage
}) => {
  return (
    <div>
      {label ? <label htmlFor={id}>{label}</label> : null}
      <input
        id={id}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        onBlur={onBlur}
        placeholder={placeholder}
      />
      {error && errorMessage ? <p className="error-message">{errorMessage}</p> : null}
    </div>
  );
};

To work with custom components, we use the Controller wrapper, which simplifies their integration.

  • control: The object returned from invoking useForm. This is optional when using FormProvider.
  • name: A unique name for your input.
  • render: A function that returns the field object, which contains properties provided by the Controller.
  • field.value: The current value of the controlled component.
  • field.onChange: A function that updates the library with the value.

The key pattern here is that we wrap the component in a Controller and pass an onChange prop, which is a callback function. Inside that callback, we access the selected item's value and pass it to Hook Form via the field.onChange method. Learn more in the official docs: Controller.

Select

I'll also include a simple example of a custom select using the React Select library, which is nearly the same as the custom input.

<Controller
  name="gender"
  control={control}
  render={({ field }) => (
    <Select
      options={options}
      placeholder="Select"
      defaultValue={field.value}
      onChange={(selectedItem) =>
        field.onChange(selectedItem?.value) 
      }
    />
  )}
/>

Multiple checkboxes

Now for a more complex example: Here, we have a component that functions as multiple checkboxes styled as select cards, allowing users to choose different days of the week. This example is inspired by a real fitness project I worked on called GoChamp.

Multiple checkboxes example
Multiple checkboxes example
Multiple checkboxes error example
Multiple checkboxes error example

It’s integrated with React Hook Form via Controller and includes validation. To proceed, at least one day must be selected.

Here’s a full code example. It may look extensive, but that’s only because we use a map and filter here. I didn’t want to abstract that away since showing the full process makes it easier to understand how everything connects - which is actually very simple.

<Controller
  control={control}
  name="weeklySchedule"
  render={({ field }) => (
    <CheckboxCardsGroup
      items={DAYS_OF_WEEK.map((label) => ({
        label,
        checked: field.value ? field.value.includes(label) : false,
      }))}
      checkedItems={field.value || []}
      handleCheckboxChange={(label) => {
        if (field.value) {
          const newCheckedItems = field.value.includes(label)
            ? field.value.filter((item) => item !== label)
            : [...field.value, label];
          field.onChange(newCheckedItems);
          setUserAnswersSessionData("weeklySchedule", newCheckedItems);
        }
        if (scheduleError) {
          clearErrors("weeklySchedule");
        }
      }}
    />
  )}
/>;

type Props = {
  checkedItems: string[];
  items: { label: string; checked: boolean }[];
  handleCheckboxChange: (label: string) => void;
};

const CheckboxCardsGroup: FC<Props> = ({
  items,
  checkedItems,
  handleCheckboxChange,
}) => {
  return (
    <>
      {items.map((item, index) => (
        <label
          key={index}
          className={`${s.labelRoot} ${
            checkedItems.includes(item.label) ? s.active : ""
          }`}
          htmlFor={item.label}
        >
          <RadixCheckbox.Root
            className={s.checkboxRoot}
            checked={checkedItems.includes(item.label)}
            onCheckedChange={() => handleCheckboxChange(item.label)}
            id={item.label}
          >
            <RadixCheckbox.Indicator>
              <CheckmarkIcon />
            </RadixCheckbox.Indicator>
          </RadixCheckbox.Root>
          {item.label}
        </label>
      ))}
    </>
  );
};

Do you see the pattern here? Just like in the previous example with the select component, we wrap the checkboxes in a Controller. The key is to pass the checked value to Hook Form using field.onChange(newCheckedItems). This demonstrates why passing a callback function as a prop is so powerful - it allows us to validate the value or implement custom logic before sending it to Hook Form.

Additionally, we can clear the error when selecting a new value, like this:

if (scheduleError) {
  clearErrors("weeklySchedule");
}

Date picker

Here’s another example with a date picker. Think this one can be difficult? Of course not! It works just like the previous examples.

<Controller
  name='birthDate'
  control={control}
  render={({ field }) => (
    <DatePicker
      value={field.value}
      label='Birth date'
      onClick={(value) => field.onChange(value)}
      errorMessage={errors.birthDate?.message}
    />
  )}
/>

Just like with other components, we wrap the date picker in a Controller to manage it. Whenever a date is selected, we pass the value to React Hook Form using the field.onChange method.

And that’s basically it! Of course, there’s always more you can do, but the basics of connecting any component are as simple as this.

Share on:

Contact Me

Have an opportunity, wanna collaborate on something cool or just say hello!

Send Email