Managing Forms with React Hook Form

Updated on · 9 min read
Managing Forms with React Hook Form

Dealing with forms in React can be a challenging task, especially when incorporating dynamic fields. Thankfully, several libraries are available to make this process more manageable. One such library is React Hook Form. Rather than relying on predefined form components, React Hook Form leverages hooks to give developers control over the form's behavior while allowing them to define their own component implementations. This flexibility means that users are not confined to any specific UI framework or set of form components.

In this tutorial, we will go through the process of creating a simple recipe form using React Hook Form. This form will include basic details, as well as a dynamic list of ingredients. By the end of this post, you will have a functional form that looks like this:

Final form

The final version of the code is available on GitHub and CodeSandbox.

This post covers the fundamentals of working with React Hook Form. If you're curious about more advanced use cases, you may find these posts helpful:

Setup

Apart from React Hook Form, we'll be using @emotion/styled library to add a bit of styling to the form components.

We begin by installing all the required dependencies.

bash
npm i @emotion/core @emotion/styled react-hook-form
bash
npm i @emotion/core @emotion/styled react-hook-form

Now we can set up our form component in a new file, called RecipeForm.js.

jsx
// RecipeForm.js import styled from "@emotion/styled"; export const Recipe = () => { return ( <Container> <h1>New recipe</h1> </Container> ); }; const Container = styled.div` display: flex; flex-direction: column; max-width: 700px; `;
jsx
// RecipeForm.js import styled from "@emotion/styled"; export const Recipe = () => { return ( <Container> <h1>New recipe</h1> </Container> ); }; const Container = styled.div` display: flex; flex-direction: column; max-width: 700px; `;

Form Basics

With all this setup out of the way, we can finally start working on the form itself. We'll begin with the Basics section, which will have general information about the recipe. To help with grouping form fields into sections, let's add a custom component, called FieldSet, which is a small abstraction on top of the native HTML fieldset.

jsx
// FieldSet.js import styled from "@emotion/styled"; export const FieldSet = ({ label, children }) => { return ( <Container> {label && <Legend>{label}</Legend>} <Wrapper>{children}</Wrapper> </Container> ); }; const Container = styled.fieldset` margin: 16px 0; padding: 0; border: none; `; const Wrapper = styled.div` display: flex; justify-content: space-between; flex-direction: column; align-items: self-start; `; const Legend = styled.legend` font-size: 16px; font-weight: bold; margin-bottom: 10px; `;
jsx
// FieldSet.js import styled from "@emotion/styled"; export const FieldSet = ({ label, children }) => { return ( <Container> {label && <Legend>{label}</Legend>} <Wrapper>{children}</Wrapper> </Container> ); }; const Container = styled.fieldset` margin: 16px 0; padding: 0; border: none; `; const Wrapper = styled.div` display: flex; justify-content: space-between; flex-direction: column; align-items: self-start; `; const Legend = styled.legend` font-size: 16px; font-weight: bold; margin-bottom: 10px; `;

Additionally, we will streamline the form field logic by extracting it into a separate component called Field. This component will be responsible for rendering input elements and their corresponding labels, as well as managing field error logic, making our code more readable and maintainable.

jsx
// Field.js import React from "react"; import styled from "@emotion/styled"; export const Field = ({ label, children, htmlFor, error }) => { const id = htmlFor || getChildId(children); return ( <Container errorState={!!error}> {label && <Label htmlFor={id}>{label}</Label>} {children} {!!error && <ErrorMessage role="alert">{error.message}</ErrorMessage>} </Container> ); }; const getChildId = (children) => { const child = React.Children.only(children); if ("id" in child?.props) { return child.props.id; } }; const Container = styled.div` display: flex; flex-direction: column; align-content: flex-start; justify-content: flex-start; margin: 16px 0; padding: 0; border: none; width: 100%; input, textarea { border-color: ${({ errorState }) => (errorState ? "red" : "#d9d9d9")}; } `; const Label = styled.label` margin-bottom: 2px; `; const ErrorMessage = styled.div` color: red; font-size: 14px; `;
jsx
// Field.js import React from "react"; import styled from "@emotion/styled"; export const Field = ({ label, children, htmlFor, error }) => { const id = htmlFor || getChildId(children); return ( <Container errorState={!!error}> {label && <Label htmlFor={id}>{label}</Label>} {children} {!!error && <ErrorMessage role="alert">{error.message}</ErrorMessage>} </Container> ); }; const getChildId = (children) => { const child = React.Children.only(children); if ("id" in child?.props) { return child.props.id; } }; const Container = styled.div` display: flex; flex-direction: column; align-content: flex-start; justify-content: flex-start; margin: 16px 0; padding: 0; border: none; width: 100%; input, textarea { border-color: ${({ errorState }) => (errorState ? "red" : "#d9d9d9")}; } `; const Label = styled.label` margin-bottom: 2px; `; const ErrorMessage = styled.div` color: red; font-size: 14px; `;

Take note that we've included a handy utility function, getChildId. This function automatically assigns the htmlFor attribute to the label based on the id attribute of the child element. By doing so, it effectively links the label and input elements, enhancing the form's accessibility. In case we need to specify a custom htmlFor attribute we add an htmlFor prop.

Now it's time to create the form. For this simple recipe form, we'll only have a few basic fields, such as recipe name, description, and number of servings.

jsx
import styled from "@emotion/styled"; import { FieldSet } from "./FieldSet.js"; import { Field } from "./Field.js"; export const RecipeForm = () => { return ( <Container> <h1>New recipe</h1> <form> <FieldSet label="Basics"> <Field label="Name"> <Input type="text" name="name" id="name" /> </Field> <Field label="Description"> <TextArea name="description" id="description" rows={10} /> </Field> <Field label="Servings"> <Input type="number" name="amount" id="amount" /> </Field> </FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> ); }; const Container = styled.div` display: flex; flex-direction: column; max-width: 700px; `; const Input = styled.input` box-sizing: border-box; padding: 10px; width: 100%; border: 1px solid #d9d9d9; border-radius: 6px; `; const TextArea = styled.textarea` box-sizing: border-box; padding: 4px 11px; width: 100%; border: 1px solid #d9d9d9; border-radius: 6px; `; const Button = styled.button` font-size: 14px; cursor: pointer; padding: 0.6em 1.2em; border: 1px solid #d9d9d9; border-radius: 6px; margin-right: auto; background-color: ${({ variant }) => variant === "primary" ? "#3b82f6" : "white"}; color: ${({ variant }) => (variant === "primary" ? "white" : "#213547")}; `;
jsx
import styled from "@emotion/styled"; import { FieldSet } from "./FieldSet.js"; import { Field } from "./Field.js"; export const RecipeForm = () => { return ( <Container> <h1>New recipe</h1> <form> <FieldSet label="Basics"> <Field label="Name"> <Input type="text" name="name" id="name" /> </Field> <Field label="Description"> <TextArea name="description" id="description" rows={10} /> </Field> <Field label="Servings"> <Input type="number" name="amount" id="amount" /> </Field> </FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> ); }; const Container = styled.div` display: flex; flex-direction: column; max-width: 700px; `; const Input = styled.input` box-sizing: border-box; padding: 10px; width: 100%; border: 1px solid #d9d9d9; border-radius: 6px; `; const TextArea = styled.textarea` box-sizing: border-box; padding: 4px 11px; width: 100%; border: 1px solid #d9d9d9; border-radius: 6px; `; const Button = styled.button` font-size: 14px; cursor: pointer; padding: 0.6em 1.2em; border: 1px solid #d9d9d9; border-radius: 6px; margin-right: auto; background-color: ${({ variant }) => variant === "primary" ? "#3b82f6" : "white"}; color: ${({ variant }) => (variant === "primary" ? "white" : "#213547")}; `;

In this step, we add the recipe fields along with their labels, resulting in the simple form shown below. Note the use of the name attributes on the form elements, as they will prove useful shortly. For the automatic htmlFor assignment to work, we need to remember to add id to all the input elements.

Basic form

Manging the form with React Hook Form

Now, let's leverage React Hook Form to manage our form's state. One of the key benefits of this library is that it simplifies state management, eliminating the need for multiple useState hooks. All we have to do is use the register function with the field's name as an argument to register fields within the form's state.

jsx
import styled from "@emotion/styled"; import { FieldSet } from "./FieldSet.js"; import { Field } from "./Field.js"; import { useForm } from "react-hook-form"; export const RecipeForm = () => { const { register, handleSubmit } = useForm(); const submitForm = (formData) => { console.log(formData); }; return ( <Container> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name"> <Input {...register("name")} type="text" id="name" /> </Field> <Field label="Description"> <TextArea {...register("description")} id="description" rows={10} /> </Field> <Field label="Servings"> <Input {...register("amount")} type="number" id="amount" /> </Field> </FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> ); };
jsx
import styled from "@emotion/styled"; import { FieldSet } from "./FieldSet.js"; import { Field } from "./Field.js"; import { useForm } from "react-hook-form"; export const RecipeForm = () => { const { register, handleSubmit } = useForm(); const submitForm = (formData) => { console.log(formData); }; return ( <Container> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name"> <Input {...register("name")} type="text" id="name" /> </Field> <Field label="Description"> <TextArea {...register("description")} id="description" rows={10} /> </Field> <Field label="Servings"> <Input {...register("amount")} type="number" id="amount" /> </Field> </FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> ); };

We begin by importing and invoking the useForm hook, which returns several helpful utilities. In this instance, we use register to associate a form field with its corresponding property in the state via the field's name. This is why adding names to the fields is crucial here. Additionally, we need to wrap our submit function in the handleSubmit callback.

Now, if we enter recipe details in the form fields and press Save, we should see the following object displayed in the console:

json
{ "name": "Pancakes", "description": "Super delicious pancake recipe", "amount": "10" }
json
{ "name": "Pancakes", "description": "Super delicious pancake recipe", "amount": "10" }

That's all the setup needed to start using React Hook Form. However, its functionality doesn't end here and next, we'll see a few enhancements we can add to our form.

Form validation and error handling

The register value returned from useForm is a function that accepts validation rules as an object for its second parameter. Several validation rules are available:

  • required
  • min
  • max
  • minLength
  • maxLength
  • pattern
  • validate

To make the recipe name a required field, all we need to do is call register with a required prop:

jsx
<Input {...register("name", { required: true })} type="text" id="name" />
jsx
<Input {...register("name", { required: true })} type="text" id="name" />

Additionally, the useForm function returns a formState object that contains an errors attribute. This attribute maps all the raised errors to their corresponding field names. For instance, in the case of a missing recipe name, the errors object would have a name attribute with a type of required.

It's worth noting that instead of specifying the required validation rule with a boolean value, we can also pass it a string, which will be used as the error message.

jsx
<Input {...register("name", { required: "This field is required" })} type="text" id="name" />
jsx
<Input {...register("name", { required: "This field is required" })} type="text" id="name" />

Alternatively, we can use the message property to specify an error message. This message can be accessed later via errors.name.message. We can then pass this error object to the Field component to toggle the error state.

By combining form validation and errors, we can display helpful messages for the users.

jsx
import styled from "@emotion/styled"; import { FieldSet } from "./FieldSet.js"; import { Field } from "./Field.js"; import { useForm } from "react-hook-form"; export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm(); const submitForm = (formData) => { console.log(formData); }; return ( <Container> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name}> <Input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> </Field> <Field label="Description" error={errors.description}> <TextArea {...register("description", { maxLength: { value: 100, message: "Description cannot be longer than 100 characters", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount}> <Input {...register("amount", { max: { value: 10, message: "Maximum number of servings is 10", }, })} type="number" id="amount" /> </Field> </FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> ); };
jsx
import styled from "@emotion/styled"; import { FieldSet } from "./FieldSet.js"; import { Field } from "./Field.js"; import { useForm } from "react-hook-form"; export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm(); const submitForm = (formData) => { console.log(formData); }; return ( <Container> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name}> <Input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> </Field> <Field label="Description" error={errors.description}> <TextArea {...register("description", { maxLength: { value: 100, message: "Description cannot be longer than 100 characters", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount}> <Input {...register("amount", { max: { value: 10, message: "Maximum number of servings is 10", }, })} type="number" id="amount" /> </Field> </FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> ); };

If we try to submit the form with invalid data, we get handy validation messages for the corresponding fields.

Form validation example

It's also possible to apply custom validation rules to the fields using the validate rule. This can be done by defining a function or an object of functions with different validation rules. For instance, we can validate whether the field value is equal using the following code:

jsx
<Input {...register("amount", { validate: (value) => value % 2 === 0 })} type="number" />
jsx
<Input {...register("amount", { validate: (value) => value % 2 === 0 })} type="number" />

Handling Number Inputs

In the current form, we're using a number input field for the servings. However, due to how HTML input elements work, when the form is submitted, this value will be a string in the form data. In some cases, this might not be what we want, for example, if the data is expected to be a number on the backend. One easy fix would be to convert the amount to a number on submit, but it is not optimal, especially when we have many such fields. A better solution would be to abstract the number input into a separate component with type conversion logic. That way, when the form is submitted, the data has the types we need.

Let's create such a component called NumberInput.

jsx
// NumberInput.js import styled from "@emotion/styled"; export const NumberInput = ({ value, onChange, ...rest }) => { const handleChange = (e) => { const value = e.target.valueAsNumber || 0; onChange(value); }; return ( <Input type="number" min={0} onChange={handleChange} value={value} {...rest} /> ); }; const Input = styled.input` padding: 10px; width: 100%; border: 1px solid #d9d9d9; border-radius: 6px; `;
jsx
// NumberInput.js import styled from "@emotion/styled"; export const NumberInput = ({ value, onChange, ...rest }) => { const handleChange = (e) => { const value = e.target.valueAsNumber || 0; onChange(value); }; return ( <Input type="number" min={0} onChange={handleChange} value={value} {...rest} /> ); }; const Input = styled.input` padding: 10px; width: 100%; border: 1px solid #d9d9d9; border-radius: 6px; `;

Here, we use the valueAsNumber property of the event target object to get the numerical representation of the input value. The valueAsNumber property returns NaN if the conversion of the input value to a number is impossible. Therefore, we provide 0 as a default value for such cases.

After that, we can replace the current amount field with this new component.

jsx
import styled from "@emotion/styled"; import { FieldSet } from "./FieldSet.js"; import { Field } from "./Field.js"; import { useForm, Controller } from "react-hook-form"; import { NumberInput } from "./NumberInput.js"; export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, control, } = useForm(); const submitForm = (formData) => { console.log(formData); }; return ( <Container> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name}> <Input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> </Field> <Field label="Description" error={errors.description}> <TextArea {...register("description", { maxLength: { value: 100, message: "Description cannot be longer than 100 characters", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount} htmlFor="amount"> <Controller name="amount" control={control} defaultValue={1} render={({ field: { ref, ...field } }) => ( <NumberInput {...field} id="amount" /> )} rules={{ max: { value: 10, message: "Maximum number of servings is 10", }, }} /> </Field> </FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> ); };
jsx
import styled from "@emotion/styled"; import { FieldSet } from "./FieldSet.js"; import { Field } from "./Field.js"; import { useForm, Controller } from "react-hook-form"; import { NumberInput } from "./NumberInput.js"; export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, control, } = useForm(); const submitForm = (formData) => { console.log(formData); }; return ( <Container> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name}> <Input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> </Field> <Field label="Description" error={errors.description}> <TextArea {...register("description", { maxLength: { value: 100, message: "Description cannot be longer than 100 characters", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount} htmlFor="amount"> <Controller name="amount" control={control} defaultValue={1} render={({ field: { ref, ...field } }) => ( <NumberInput {...field} id="amount" /> )} rules={{ max: { value: 10, message: "Maximum number of servings is 10", }, }} /> </Field> </FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> ); };

To connect this component to the form, React Hook Form provides a Controller - a wrapper for working with controlled external components.

Instead of using the register function, we can use the control object that we get from useForm. For validation, we use the rules prop. We still need to add the name attribute to the Controller to register it. Then, we pass the input component via a render prop. This way, the data for the recipe servings will be saved to the form as before, while using an external component.

Controlled vs uncontrolled inputs in React Hook Form

In React Hook Form, the difference between controlled and uncontrolled components is similar to traditional React components. Uncontrolled components in React Hook Form refer to inputs that are registered using the register function and have their state managed by the DOM. Controlled components, on the other hand, use the useForm hook to register inputs and manage their state by being wrapped into the Controller component.

One advantage of using controlled components in React Hook Form is that they provide more granular control over the input state, allowing for easier integration with external libraries and more complex validation logic. Uncontrolled components can be simpler to implement, especially for simple forms, but can be more difficult to customize for more complex use cases.

Handling dynamic fields with useFieldArray

No recipe is complete without its ingredients. However, we can't add fixed ingredient fields to our form since the number of ingredients varies depending on the recipe. Normally, we would need to create our custom logic for handling dynamic fields. However, React Hook Form comes with a custom hook for working with dynamic inputs called useFieldArray. This hook takes the form's control object and the name for the field, returning several utilities for working with dynamic inputs. Let's see it in action by adding the ingredients fields to our recipe form.

jsx
import styled from "@emotion/styled"; import { FieldSet } from "./FieldSet.js"; import { Field } from "./Field.js"; import { useForm, Controller, useFieldArray } from "react-hook-form"; import { NumberInput } from "./NumberInput.js"; export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, control, } = useForm(); const { fields, append, remove } = useFieldArray({ name: "ingredients", control, }); const submitForm = (formData) => { console.log(formData); }; return ( <Container> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name}> <Input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> </Field> <Field label="Description" error={errors.description}> <TextArea {...register("description", { maxLength: { value: 100, message: "Description cannot be longer than 100 characters", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount} htmlFor="amount"> <Controller name="amount" control={control} defaultValue={1} render={({ field: { ref, ...field } }) => ( <NumberInput {...field} id="amount" /> )} rules={{ max: { value: 10, message: "Maximum number of servings is 10", }, }} /> </Field> </FieldSet> <FieldSet label="Ingredients"> {fields.map((field, index) => { return ( <Row key={field.id}> <Field label="Name"> <Input type="text" {...register(`ingredients[${index}].name`)} id={`ingredients[${index}].name`} /> </Field> <Field label="Amount"> <Input type="text" {...register(`ingredients[${index}].amount`)} defaultValue={field.amount} id={`ingredients[${index}].amount`} /> </Field> <Button type="button" onClick={() => remove(index)}> &#8722; </Button> </Row> ); })} <Button type="button" onClick={() => append({ name: "", amount: "" })} > Add ingredient </Button> </FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> ); }; const Row = styled.div` display: flex; align-items: center; justify-content: space-between; width: 100%; & > * { margin-right: 20px; } button { margin: 25px 0 0 8px; } `;
jsx
import styled from "@emotion/styled"; import { FieldSet } from "./FieldSet.js"; import { Field } from "./Field.js"; import { useForm, Controller, useFieldArray } from "react-hook-form"; import { NumberInput } from "./NumberInput.js"; export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, control, } = useForm(); const { fields, append, remove } = useFieldArray({ name: "ingredients", control, }); const submitForm = (formData) => { console.log(formData); }; return ( <Container> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name}> <Input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> </Field> <Field label="Description" error={errors.description}> <TextArea {...register("description", { maxLength: { value: 100, message: "Description cannot be longer than 100 characters", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount} htmlFor="amount"> <Controller name="amount" control={control} defaultValue={1} render={({ field: { ref, ...field } }) => ( <NumberInput {...field} id="amount" /> )} rules={{ max: { value: 10, message: "Maximum number of servings is 10", }, }} /> </Field> </FieldSet> <FieldSet label="Ingredients"> {fields.map((field, index) => { return ( <Row key={field.id}> <Field label="Name"> <Input type="text" {...register(`ingredients[${index}].name`)} id={`ingredients[${index}].name`} /> </Field> <Field label="Amount"> <Input type="text" {...register(`ingredients[${index}].amount`)} defaultValue={field.amount} id={`ingredients[${index}].amount`} /> </Field> <Button type="button" onClick={() => remove(index)}> &#8722; </Button> </Row> ); })} <Button type="button" onClick={() => append({ name: "", amount: "" })} > Add ingredient </Button> </FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> ); }; const Row = styled.div` display: flex; align-items: center; justify-content: space-between; width: 100%; & > * { margin-right: 20px; } button { margin: 25px 0 0 8px; } `;

useFieldArray returns several utilities for managing dynamic fields, including append, remove, and the array of the fields themselves. The complete list of utility functions is available on the library's documentation site. Since we do not have default values for ingredients, the field is initially empty. We can start populating it by using the append function and providing default values for empty fields. Note that the fields are rendered by their index in the array, so it's important to have field names in the format fieldArrayName[fieldIndex][fieldName]. We can also delete fields by passing the index of the field to the delete function. Now, after adding a few ingredient fields and filling in their values, when we submit the form, all those values will be saved in the ingredients field in the form.

That's all it takes to build a fully functional and easily manageable form with React Hook Form. The library has plenty more features, not covered in this post, so make sure to check the documentation for more examples.

Testing the form

Testing forms is an essential part of the development process as it helps ensure that the form works correctly and can catch any future regressions.

Testing forms is a broad topic on its own, and it is covered in a separate post: Testing React Hook Form With React Testing Library.

Conclusion

In this tutorial, we covered the basics of using React Hook Form, a library for managing forms in React. We created a simple recipe form, including basic fields like recipe name, description, and servings. We then learned how to handle form validation and error messages, as well as how to create custom validation rules. We also demonstrated how to use the Controller component for working with controlled input fields.

Finally, we explored how to add dynamic fields using the useFieldArray hook, which allowed us to dynamically add and remove ingredients. By the end of the tutorial, we had a fully functional form that can handle dynamic fields with ease.

Dealing with forms in React can indeed be challenging, especially when it comes to dynamic fields. However, as we've demonstrated in this tutorial, using React Hook Form can significantly simplify the process. With features like built-in validation, error handling, and support for dynamic fields, React Hook Form is a powerful tool for building robust and maintainable forms.

References and resources