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.
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.
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>
</>
);
};
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 />}
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)
}
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>
);
};
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-byee.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 />
</>
);
};
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 />
</>
);
};
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>
);
};
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>
);
};
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:
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
Top comments (0)