Composable Form with react-hook-form

Ferdinand - Aug 17 - - Dev Community

react-hook-form-image

Have you ever built a page with a form as a frontend developer? Well, I bet you have. Forms are commonly used especially in CRUD (Create Read Update Delete) pages. Most of the time, the create and update form has the same UI. The only difference is that in the update form, it will be prefilled with value. Create and Edit usually have the same validation too. For example, the max character for an input, the required inputs, etc.

Create Form

Update Form

Looking at this case, there's no denying that we want to optimize our code so that we don't have to repeat the implementation of the component along with its validation logic. Let's take a look at some of the approaches that we usually take!

1. Create two separate components, namely: CreateForm and UpdateForm

We could "blatantly" create two separate components. But I think the problem here is self-explanatory. Having two separate components means we will have duplicated components, duplicated logic, and duplicated validation. It's double the work and maintenance. The issue is that sometimes when we update one, there's a possibility that we miss updating the other one. There's a high possibility that most of the logic, validation, and Form UI for both create and update are quite similar.

Create Edit Diagram

Upon facing this kind of issue, we usually approach it with an obvious course of option: abstraction.

2. A Form That Both Handles Create and Edit

The first approach is to make a shared form component that handles both create and update flow.

import { useState } from "react";

interface Props {
  defaultTitle?: string;
  defaultCount?: number;
  onSubmit: (submittedData: Record<string, any>) => void;
}

export const CreateEditForm = (props: Props) => {
  const { defaultCount, defaultTitle, onSubmit } = props;
  const [title, setTitle] = useState(defaultTitle);
  const [count, setCount] = useState(defaultCount);

  return (
    <>
      <form>
        <label htmlFor="title">Title</label>
        <br />
        <input
          type="text"
          name="title"
          id="title"
          value={title}
          onChange={(e) => {
            // Add validation here, if valid
            setTitle(e.currentTarget.value);
          }}
        />
        <br />
        <label htmlFor="count">Number of item</label>
        <br />
        <input
          type="number"
          name="count"
          id="count"
          value={count}
          onChange={(e) => {
            // Add validation here, if valid
            setCount(Number(e.currentTarget.value));
          }}
        />
        <br />
        <br />
        <button type="button" onClick={() => onSubmit({ title, count })}>
          Submit
        </button>
      </form>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

My issue with this approach is that while at first, it looks like it's okay and innocent enough - the code could easily go rogue. Say in the future, there's a requirement for differentiating create and edit either for its UI or validation - this code will get more complex and just waiting to become a spaghetti code. The abstraction doesn't seem to be worth it anymore. There's going to be a lot of isUpdate checks happening inside. Let's say, for example, we want to show a certain component only in the update form:

{isUpdate && <ComponentOnlyForUpdate />}
Enter fullscreen mode Exit fullscreen mode

Or when we want to have a different handle for a certain field for the create form:

const handleTitleInput = (titleInput: string) => {
  // handling for create form
  if (!isUpdate) {
      const isValid = validateTitleInputForCreate(titleInput)
      // check if the input valid only in create form
      if (isValid) {
        setTitle(titleInput)
      } else {
        showSnackbar('Title is not valid for create')
      }
  }

  // In update form, set input immediately without validation
  setTitle(titleInput)
}
Enter fullscreen mode Exit fullscreen mode

The bottom line is that this component could get more bulky and at some point, doesn't seem to be a shared component anymore cause there's a lot of differentiation happening inside.

The other drawback is that we must uplift the input states to the parent component that loads the form view. As the number of form fields increases, the shared component is more likely to become unmaintainable too.

So the question here is: "Is there a way to create a form that still has commonality so that it's easy to maintain but also retains some degree of flexibility?" 🤔

Meet react-hook-form

3. Introducing react-hook-form

react-hook-form is a React-compatible library that provides utilities to build performant, flexible, and extensible forms with easy-to-use validation. It's quite easy to use:

const Create = () => {
  const { register, handleSubmit } = useForm();

  const onSubmit = (data: { title: string; count: number }) => console.log(data);

  return (
    <form>
      <label htmlFor="title">Title</label>
      <br />
      <input type="text" {...register("title")} />
      <br />
      <label htmlFor="count">Number of item</label>
      <br />
      <input type="number" {...register("count")} />
      <br />
      <br />
      <button type="button" onClick={handleSubmit(onSubmit)}>
        Submit
      </button>
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

In the code above, each field is registered to a unique key. These keys will be passed on to the data variable by wrapping the callback function with handleSubmit

Pros:

  • Data input states are managed internally by the library so we don't have to worry about state management. Bye-bye useState 👋
  • We don't have to write the onChange callback logic by ourselves. Bye-bye e.currentTarget.value 👋

Now, do you see the pattern? How are they using a hook to manage the form data? Do you know what that means? That means we can detach each of the fields and create an atomic component. These atomic components then can be used for both UI (Create and Update) - as long as the page knows what field IDs to read. Let's isolate each of the form fields along with their ID and create a field-input-type component.

First, TitleInput

import { useFormContext } from "react-hook-form";

export const TitleInput = () => {
  const { register } = useFormContext();

  return (
    <>
      <label htmlFor="title">Title</label>
      <br />
      <input type="text" {...register("title")} />
      <br />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

In this case, we are using useFormContext instead. Why? I'll be there in a sec', just bear with it for now. The input of this component is registered as title

Then, let's create a component called NumberInput. The input of this component is registered as count

import { useFormContext } from "react-hook-form";

export const NumberInput = () => {
  const { register } = useFormContext();
  return (
    <>
      <label htmlFor="count">Number of item</label>
      <br />
      <input type="number" {...register("count")} />
      <br />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now we're ready with our field components, we can start composing our create and edit form separately using 'em.

import { NumberInput } from "@/src/NumberInput";
import { TitleInput } from "@/src/TitleInput";
import { FormProvider, useForm } from "react-hook-form";

const Create = () => {
  const methods = useForm();

  const onSubmit = (data: { title: string; count: number }) => console.log(`call create API with`, data);

  return (
    <FormProvider {...methods}>
      <form>
        <TitleInput />
        <NumberInput />
        <br />
        <button type="button" onClick={methods.handleSubmit(onSubmit)}>
          Submit
        </button>
      </form>
    </FormProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Usually, only using useForm is sufficient in this case - But! Since we want to extract our field-input components (TitleInput & NumberInput), we have to make use of FormProvider. This provider will act as a host context object, and the children of this provider can access useForm hook's props and methods via useFormContext. You can read more in the documentation here.

As you can see, we simply imported the TitleInput and NumberInput. The value that gets inputted on those fields will be present in the data variables inside the onSubmit's parameters. Since this is a Create page, we can flexibly do what needs to be done on this page without having to touch on the Update page. In this case, upon submitting, we want to call /create API endpoint.

import { NumberInput } from "@/src/NumberInput";
import { TitleInput } from "@/src/TitleInput";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";

const Update = () => {
  const methods = useForm();
  const [isChecked, setIsChecked] = useState(false);

  const onUpdate = (data: { title: string; count: number }) => {
    if (isChecked) {
      console.log(`call update API with`, data);
    } else {
      alert("You need to check the box first");
    }
  };

  return (
    <FormProvider {...methods}>
      <form>
        <TitleInput />
        <NumberInput />

        <br />

        <div>
          <input
            type="checkbox"
            id="sign"
            name="sign"
            checked={isChecked}
            onChange={() => setIsChecked(!isChecked)}
          />
          <label for="sign">Are you sure you wanna update?</label>
        </div>

        <br />
        <button type="button" onClick={methods.handleSubmit(onUpdate)}>
          Update
        </button>
      </form>
    </FormProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Same goes for the Update page. Cause we separate the two pages, we can do whatever we want on this page without bothering the Create page. In this case:

  • The button uses Update text
  • There's a new input that is not present in the Create page ( checkbox)
  • Slightly different handler in which there's an API call or showing an alert
  • Call /update API endpoint instead

As for the result:

Create page with react hook form

Update page with react hook form

Now, with this approach, we can have a composable customizable form with shared components without having to sacrifice code quality with a bunch of copy-paste code. Some pros are:

  • There are no duplicated states that hold the input value for both pages
  • Any updates to the form fields will be reflected on both pages simultaneously. Like UI or validation changes.
  • We don't have to make it complicated by abstracting it into a shared component. We just have to share the smallest piece of the UI which is the form fields
  • We still have some degree of segregation between the two pages. Mainly on how to handle the data, what API to call, or any special handling that only happens on one of the page

Using react-hook-form library proved to be beneficial. In the end, we got the benefit of maintainable shared UI while also having flexibility on customization based on the page needs. It's simple, lightweight, and easy to use. I encourage you to try it!

Hope you enjoy the post,
See you in a bit!
ferzos-Opinionated FE Engineer

Reference

. . .
Terabox Video Player