Powerful type-checking of React-hook-form and Learning Notes

The first time I touched the React-hook-form was in 2020 and it also was the first time I learned React's new concept Hooks. During that time we compared all the popular form libraries, Formik, react-form, final form and etc, but we finally chose the React-hook-form even though it was still under the development during that time. It is because it uses React latest concept Hooks. And React was our fundamental framework in my previous company, so we didn't need to think about the compatibility.

Now it comes to the version 7 and there are more hooks. And these days I began to use it in our project to create multiple forms. After discussing with my colleague Rodrigo, I had a better deeper understanding to React-hook-form's built-in schema validation when develop with typescript. Before we go deep, let me clear some basic concepts.

Controlled vs Uncontrolled component

This is the concept introduced by the React and the difference of them is where you will keep the source of truth. For the controlled components, we will pass a React controlled value to the form element,, React will take care of the state and render the component whenever you change the value. But for uncontrolled components, the state is saved in the DOM, which means when you update the value of form element, React will not notify it. We have to manually extract the value when need (through React's ref). But the benefit of that is it will not render the component either.

Let me clear it with the simple codes:

Controlled Component

1const ControlledComponent = () => {
2  const [someValue, setSomeValue] = useState()
3  const handleChange= (event) => setSomeValue(event.target.value)
4
5  return <input value={someValue} onChange={handleChange} />
6}

So this Controlled Component will monitor the input value and keep pushing the value to the element.

A form element becomes "controlled" if you set its value via a prop. That's all.

NOTE: checked for Radio and Checkbox.

NOTE: it's fine to pass onChange to form element, only setting its value will make it controlled. (I used to think the property onChange or onClick decides if the component is controlled or uncontrolled as well)

Uncontrolled Component

 1const UncontrolledComponent = () => {
 2  const testRef = useRef()
 3
 4  const handleButtonClick = () => {
 5    const value = testRef.current?.value;
 6    // do something to the value
 7  }
 8
 9  return (
10    <div>
11        <input type="text" ref={testRef} />
12        <button onClick={handleButtonClick} />
13    </div>
14  )
15}

Since the value is not assigned to the element input, React would not know its value you typed. To get the value, we can use the ref.

Register

So for uncontrolled component, React-hook-form will use register method to generate the related methods for the form element without the property value.

 1const { onChange, onBlur, name, ref } = register('firstName'); 
 2        
 3<input 
 4  onChange={onChange} // assign onChange event 
 5  onBlur={onBlur} // assign onBlur event
 6  name={name} // assign name prop
 7  ref={ref} // assign ref prop
 8/>
 9// same as above
10<input {...register('firstName')} />

But for Controlled components, like material UI, it's recommended to use Controller. Its property render will pass a parameter field containing value to the Controlled Component.

 1<Controller
 2  control={control}
 3  name="test"
 4  render={({
 5    field: { onChange, onBlur, value, name, ref },
 6    fieldState: { invalid, isTouched, isDirty, error },
 7    formState,
 8  }) => (
 9    <Checkbox
10      onBlur={onBlur} // notify when input is touched
11      onChange={onChange} // send value to hook form
12      checked={value}
13      inputRef={ref}
14    />
15  )}
16/>

Typescript with Schema

OK, before we go further, let's check one important of react-hook-form's features - schema validation. This is not a new thing, all the form libraries support schema validation and this is a kind of standard. But with help of typescript, react-hook-form provides a very powerful type-safe checking. I can explain this with example later. Let's simply check the setup. Here we will use Zod, which seems the best choice currently according to this article

 1const schema = z.object({
 2  name: z.string(),
 3  age: z.number()
 4});
 5
 6type Schema = z.infer<typeof schema>;
 7
 8const App = () => {
 9  const { register, handleSubmit } = useForm<Schema>({ resolver: zodResolver(schema) });
10
11  //...
12}

Type checking

React-hook-form provides very powerful type-checking, it also exports multiple types used with typescript generic type. This helps us a lot with good developer experience which is also one goal of this library's design and philosiphy.

To find out the power of type checking, let's compare a correct example to a tricky one.

According to the official doc of setValue, it's possible to set value to an unregistered field. When we set value to an unregistered field, we will registered in this field as well.

1// you can setValue to a unregistered input
2setValue('notRegisteredInput', 'value'); // ✅ prefer to be registered

Setup schema and initial value

So go further, let's choose Material UI's component Select as an Controlled example. Before defining the component, setup the schema and initial value first.

 1// Select Option array
 2const SelectValue = [
 3  { value: "10", label: "The entire place" },
 4  { value: "20", label: "A private room" },
 5  { value: "30", label: "A shared room" },
 6] as const;
 7
 8// define the schema and type through Zod
 9type Property = typeof SelectValue[number]["value"];
10const VALUES: [Property, ...Property[]] = [
11  SelectValue[0].value,
12  ...SelectValue.slice(1).map((p) => p.value),
13];
14
15const PropertySchema = z.enum(VALUES);
16
17// the Zod schema used for resolver in form
18const formSchema = z.object({ example: PropertySchema });
19
20type Inputs = z.infer<typeof formSchema>;
21
22// default values provided to form
23const initValues: Inputs = { example: "10" };

Tricky Controlled Component

Then we can create a simple tricky component with Mui Select, which will not register the field in the form in advance, just pass the react-hook-form's types values control, setValue as parameters to allow the component to obtain and update the value.

 1type Props<T extends FieldValues> = {
 2  name: FieldPath<T>;
 3  control: Control<T>;
 4  setFormValue: UseFormSetValue<T>;
 5  data: ReadonlyArray<{
 6    label: string;
 7    value: FieldPathValue<T, FieldPath<T>>;
 8  }>;
 9};
10
11const Input1 = <T extends FieldValues>({
12  name,
13  control,
14  setFormValue,
15  data,
16}: Props<T>) => {
17  const value = useWatch({ name, control });
18
19  const handleChange = (event: SelectChangeEvent) => setFormValue(
20    name,
21    (event.target.value + "11") as FieldPathValue<T, FieldPath<T>>
22  );
23
24  return (
25    <FormControl fullWidth>
26      <InputLabel id="demo-simple-select-label">Age</InputLabel>
27      <Select
28        labelId="demo-simple-select-label"
29        id="demo-simple-select"
30        value={value}
31        label="Age"
32        onChange={handleChange}
33      >
34        {data.map((entry) => {
35          return (
36            <MenuItem key={entry.value} value={entry.value}>
37              {entry.label}
38            </MenuItem>
39          );
40        })}
41      </Select>
42    </FormControl>
43  );
44};

For the input parameters, we use react-hook-form's types to let it check the types of name and values for us.

  • name: FieldPath<T>
  • control: Control<T>
  • setFormValue: UseFormSetValue<T>
  • value: FieldPathValue<T, FieldPath<T>>

And when the value changes, we will use the input method setFormValue to set the field value directly. But here we do a tricky thing, instead of using the value from the option, we manipulate it by appending a string "11".

1const handleChange = (event: SelectChangeEvent) => {
2  setFormValue(
3    name,
4    (event.target.value + "11") as FieldPathValue<T, FieldPath<T>>
5  )
6}

Let's use this component in the form:

 1function App() {
 2  const methods = useForm<Inputs>({
 3    defaultValues: initValues,
 4    mode: "onChange",
 5    resolver: zodResolver(formSchema),
 6  });
 7
 8  const {
 9    register,
10    control,
11    handleSubmit,
12    setValue,
13    formState: { errors },
14  } = methods;
15
16  const onSubmit: SubmitHandler<Inputs> = (data) => console.log(data, errors)
17
18  return (
19    <div className="App">
20      <form onSubmit={handleSubmit(onSubmit)}>
21        <Input1 name="example" setFormValue={setValue} control={control} data={SelectValue} />
22        {errors.example && errors.example.message}
23        <input type="submit" />
24      </form>
25    </div>
26  );
27}

We display the error if it exists. When we select the value in the Select, since the value has been manipulated, Select will be blank. But the error does not show either, only warning in the console. Definitely, this is not good, we do not have enough information about our error.

Correct Controlled Component

Let's create a correct controlled component and pass it wrong data, see how it gives our error message.

 1const SelectValueWrong = [
 2  { value: "101", label: "The entire place" }, // This value is different from the one in type and schema
 3  { value: "20", label: "A private room" },
 4  { value: "30", label: "A shared room" },
 5] as const;
 6
 7type Props3<T extends FieldValues> = {
 8  name: FieldPath<T>;
 9  control: Control<T>; // And we do not need to pass the method setValue since react-hook-form will handle it for us
10  data: ReadonlyArray<{
11    label: string;
12    value: FieldPathValue<T, FieldPath<T>>;
13  }>;
14};
15
16const Input3 = <T extends FieldValues>({ name, control, data }: Props3<T>) => (
17  <Controller
18    control={control}
19    name={name}
20    render={({ field }) => {
21      return (
22        <FormControl fullWidth>
23          <InputLabel id="demo-simple-select-label">Age</InputLabel>
24          <Select
25            {...field}
26            labelId="demo-simple-select-label"
27            id="demo-simple-select"
28            label="Age"
29          >
30            {data.map((entry) => {
31              return (
32                <MenuItem key={entry.value} value={entry.value}>
33                  {entry.label}
34                </MenuItem>
35              );
36            })}
37          </Select>
38        </FormControl>
39      );
40    }}
41  />
42);
43
44// use it in the form
45    <Input3 control={control} name="example3"
46     data={SelectValueWrong} /> // pass the wrong select value
47    {errors.example3 && errors.example3.message}
48//...

When we select the wrong option, the value will still be displayed in the Select, but the schema validates the input value as well, set it as error and the error message is displayed.

Source codes

You can check all the codes here:

FormProvider and useFormContext? Nja

Another thought about React-hook-form is the FormProvider and useFormContext. When we pass the methods through them to the children components, we also lose the types of all the methods. Yes, of course, we can pass the type to useFormContext, but since we need to pass the form schema type twice, the type is still not 100% safe in theory. So if the form's elements are not deep, we would recommend to pass the control which would include the form's type to children components.

comments powered by Disqus