TECHNOLOGY

Custom Checkbox with React Hook Forms

Our team recently started using the React Hook Form library. We wanted to embrace React Hooks, and we liked that the library minimizes re-rendering. We've been happy with it so far, but of course have run into some small challenges here and there. In this post, I'll walk through one example: getting a custom checkbox working. Live, working versions of all the examples below can be found on this Codesandbox.

Below is a basic example of using react-hook-form with a checkbox:

import React from 'react';
import { useForm } from 'react-hook-form';

const Example = () => {
  const onSubmit = data => {
    alert(JSON.stringify(data));
  };
  const { handleSubmit, register, errors, control } = useForm();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label>
        <input
          ref={register({ required: 'This is required' })}
          name="example_1"
          value={true}
          type="checkbox"
        />
        Example 1 (basic input)
      </label>
      {errors.example_1 && <p class="error">{errors.example_1.message}</p>}
      <br />
      <button type="submit">submit</button>
    </form>
  );
};

And here's what the element looks like:

react_hook_form_example_1

This works great, but we wanted to use a more customized checkbox component that hides the actual <input type='checkbox'> node. Here's an unstyled version of our first attempt:

const Checkbox = React.forwardRef(
  ({ label, name, value, onChange, defaultChecked, ...rest }, forwardedRef) => {
    const [checked, setChecked] = React.useState(defaultChecked);

    React.useEffect(() => {
      if (onChange) {
        onChange(checked);
      }
    }, [checked]);

    return (
      <div onClick={() => setChecked(!checked)} style={{ cursor: "pointer" }}>
        <input
          style={{ display: "none" }}
          ref={forwardedRef}
          type="checkbox"
          name={name}
          value={value}
          checked={checked}
          onChange={e => {
            setChecked(e.target.checked);
          }}
        />
        [{checked ? "X" : " "}]{label}
      </div>
    );
  }
);

We'd then wire it up with react-hook-form much like the original example:

<form onSubmit={handleSubmit((data) => { console.log(data) })}>
  <Checkbox
    ref={register({ required: "This is required" })}
    name="example_2"
    value={true}
    label=" Example 2 (custom checkbox)"
  />
  {errors.example_2 && <p class="error">{errors.example_2.message}</p>}
  <button type="submit">submit</button>
</form>

At first, this seemed to work, but we eventually realized it doesn't have the exact same behavior as the first example. Specifically, if you try to submit the form and get a validation error, validation isn't re-triggered on subsequent updates to the checkbox.

react_hook_form_example_2

After some digging through the react-hook-form docs, I found the Controller element, which seems to have cases like this in mind - the docs even walk through an example of using Controller with the Material UI checkbox. Here's what it looks like for us. Note that we don't have to make any changes to our Checkbox component from above.

<form onSubmit={handleSubmit((data) => { console.log(data) })}>
  <Controller
    as={<Checkbox value={true} label=" Example 3 (Controller)" />}
    name="example_3"
    control={control}
    rules={{ required: "This is required" }}
  />
  {errors.example_3 && <p class="error">{errors.example_3.message}</p>}
  <button type="submit">submit</button>
</form>

react_hook_form_example_3

This fixes our issue with re-validation. Still, I wasn't in love with the idea of having to wrap our Checkbox in this Controller everytime we use it. I was convinced there had to be some way to maintain the clean API of the original example, where you can just pass ref={register} directly to the component.

One thought was that if it was possible to create a local ref inside our Checkbox component, we could use that to trigger actions on the hidden input, which might in turn trigger validation. But, we can't forget about the forwarded ref created with register. To get everything working together, we'd have to combine the refs. This was not as straightforward as I'd hoped, but I did find a way to do it thanks to this blog post by Daniel Ostapenko.

Here's an updated version of our Checkbox component that uses this strategy:

// first, define a helper for combining refs
function useCombinedRefs(...refs) {
  const targetRef = React.useRef();

  React.useEffect(() => {
    refs.forEach(ref => {
      if (!ref) return;

      if (typeof ref === "function") {
        ref(targetRef.current);
      } else {
        ref.current = targetRef.current;
      }
    });
  }, [refs]);

  return targetRef;
}

// then our component
const CombinedRefCheckbox = React.forwardRef(
  ({ label, name, value, onChange, defaultChecked, ...rest }, forwardedRef) => {
    const [checked, setChecked] = React.useState(defaultChecked || false);

    const innerRef = React.useRef(null);
    const combinedRef = useCombinedRefs(forwardedRef, innerRef);

    const setCheckedInput = checked => {
      if (innerRef.current.checked !== checked) {
        // just emulate an actual click on the input element
        innerRef.current.click();
      }
    };

    React.useEffect(() => {
      setCheckedInput(checked);
      if (onChange) {
        onChange(checked);
      }
    }, [checked]);

    return (
      <div onClick={() => setChecked(!checked)} style={{ cursor: "pointer" }}>
        <input
          style={{ display: "none" }}
          ref={combinedRef}
          type="checkbox"
          name={name}
          value={value}
          defaultChecked={checked}
          onChange={e => {
            setChecked(e.target.checked);
          }}
        />
        [{checked ? "X" : " "}]{label}
      </div>
    );
  }
);

There's definitely more complexity in the inner workings of this component, but as you can see below, the resulting API for actually using it is just as clean as our initial example, and validation works as expected.

<form onSubmit={handleSubmit(onSubmit)}>
  <CombinedRefCheckbox
    ref={register({ required: "This is required" })}
    name="example_4"
    value={true}
    label=" Example 4 (combined ref)"
  />
  {errors.example_4 && <p class="error">{errors.example_4.message}</p>}
  <button type="submit">submit</button>
</form>

react_hook_form_example_4

I think we'll use this approach going forward. Still, I'm glad I took the time to learn about the Controller component, too. I expect it will come in handy in the future for other custom components (date pickers, sliders, etc).

Get Started

Use our Power Calculator to find out your energy needs.

Get Started