React Hook Form: A Contextual Guide for Beginners

React Hook Form: A Contextual Guide for Beginners

·

12 min read

State management remains one of the most heavily debated topics in the React ecosystem. However, the more we reason about app state, the easier it is to realize that app state is not a single type. There’s the local state, the global state, the server state, etc. And guess what? There’s form state too.

If we think about all the states we need to manage in a form — field states, validation, error states, we can reasonably agree that our components get way more overloaded than we might sometimes desire. Well, React Hook Form solves that pretty nicely.

With hooks, React Hook Form offers a way to build complex, performant forms and manage app form states outside our form components. The APIs that make up the react-hook-form library are custom hooks. All you need do is call them in the same manner you call your React built-in hooks. In keeping with the scope of this article, we’d be examing only one of these custom hooks — the useForm() hook.

Outline

  1. The UseForm Hook

  2. Adapting to Modularity in React Components

  3. Putting Knowledge Into Practice

  4. Wrapping

The UseForm Hook

You'd need to call the useForm hook to access React Hook Form’s APIs for controlling and managing forms. This custom hook accepts as an optional argument what I’d call a configuration object. This configuration object allows you to specify desired values to predefined options to dictate how your form should function.

If you wanted to use a custom validation library like Zod to validate input fields in a form, you could specify useForm({ resolver: zodResolver(schema) }), and this would configure the source for your form’s validation rules. Other properties — mode, revalidateMode, defaultValues, to name a few, allow one to specify more configuration options for your form element.

The useForm hook returns several methods and data, which we’d use to manage individual input fields in a form. In this article, I will consider the most common return values — register, setValue, handleSubmit, reset methods, and the formState object.

Register

As the name implies, the register method allows you to register a form input and optionally add validation rules for that element. It takes a mandatory string argument which should be the name of the form input, followed by an optional object that allows you to specify validation options for that element.


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

const LoginForm = () => {
  const { register } = useForm();
  return (
    <form>
        // relevant label semantics observed
        <input type='email' {...register('email', {
            required: true
        })}
        />
    </form>
  )
}

To register an input element, your code should look like this:

<input type='email' {...register('email')} />

Because the register method will always return the values for the onChange, onBlur, ref, and name, it’s reasonable to use the spread operator. The above code, though not different, is way more elegant than doing this:

const { onChange, onBlur, name, ref } = register('email'); 

<input 
  onChange={onChange} 
  onBlur={onBlur} 
  name={name}
  ref={ref} 
/>

Form State

The formState object contains properties that tell you all you need to know about the current state of your form. These states offer a lot of useful information about the state of your form and this goes a long way in guiding your decisions. For instance,

  • isSubmitting helps you know that the submitHandler has been triggered, which is very helpful in cases where you might want to disable the submit button to avoid triggering multiple submits.

  • isValid gives information on whether there are errors on the form or otherwise. I find this very useful in determining whether the submitHandler should be called.

  • errors{} with this object, you can access individual errors and error messages on each named form input. This is useful when you want to render an error message for a form field in compliance with good UX practices.

You can access these properties by destructuring them from the formState value returned from the useForm hook invocation.

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

const LoginForm = () => {
  const { 
    register, 
    formState: {isValid, isSubmitting, errors}
    } = useForm({});
}

Handle Submit

As its name intuitively suggests, the handleSubmit function handles what happens when the submit event is triggered; It takes two callback arguments— the submit handler and the submit error handler.

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

const LoginForm = () => {
  const { 
    register, 
    formState: {isValid, isSubmitting, errors}, 
    handleSubmit } = useForm();

  // handlers
  const submitHandler = (data, event?) => {
    // execute relevant procedure
  }
  const submitErrorHandler = (errors, event?) => {
    // execute relevant procedure
  }

  return (
    <form onSubmit={handleSubmit(submitHandler, onSubmitError)}>
      <InputField 
        type='email' 
        label='email address' 
        registration={register('email')} 
      />      
    </form>
  );
}

When an onSubmit event is triggered, the handleSubmit function calls the event.preventDefault() method to prevent page reloads so the submitHandler no longer needs to do this.

The callbacks deal primarily with the data passed by react-hook-form; you can still access the event object through a second parameter.

The submitHandler can be an asynchronous function; when using an async submitHandler, you should always provide a way to adequately handle any errors since the handleSubmit method does not internally resolve them.

Reset

Because resetting a form after submission makes for a good user experience, I find the reset method one of the most relevant methods returned by the useForm hook. You can reset the entire form state, field references, and subscription; even if you don’t want to reset the whole form, the reset method accommodates a partial reset.

When resetting our form using the reset method, it may seem intuitive to reset our form state at the end of our submitHandler this way:

const submitHandler = () => {
  // submit procedure
  reset(...data);
}

Execution order matters if you don’t want to reset your form state before the data is successfully submitted. One reliable way to determine a successful submission is through the formState property called isSubmitSuccessful.

As recommended in the docs, you should call your reset method in a useEffect hook with the isSubmitSuccessful value in the dependency array, so the form state won’t reset before the data is successfully submitted as recorded in the isSubmitSuccessful value.

const { 
    register, 
    formState: {isValid, isSubmitSuccesful, errors}, 
    handleSubmit } = useForm(); 

useEffect(() => {
    if(isSubmitSuccessful) {
      reset();
    }
  }, [isSubmitSuccessful]);

Adapting To Modularity in React Components

If we strictly adhere to best practices, we’d find ourselves abstracting form inputs related to code into custom components such as InputField and TextField. In such cases, we need to register and manage form inputs dynamically. To be able to do that, we need to consider what aspects need to be controlled individually.

Two aspects come to mind:

  • The form input registration and,

  • An errors object containing individual field errors

Handling Field Registration

To handle form input registration at the form field component level, an approach I find compact is passing the register method and its relevant arguments to a registration prop.

// defining the <InputField/>
const InputField = ({type='text', label, registration}) => (
  <div>
    <label>
      <span>{label}</span>
    </label>
    <input type={type} {...registration}/>
  </div>
);

Remember, the register method takes an optional object as a second parameter, where you can add field validation rules and relevant error messages if you’re not using any third-party validation library. You can pass all of that to the register method too.

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

const LoginForm = () => {
  const {
    register, 
    // ... other destructured return values
    } = useForm();

  return (
    // rendering the <InputField/> component
    <InputField 
      //...other relevant props
      registration={ register('email', {
        required: true,
        pattern: {
          value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
          message: "Email address is not valid",
        },
      })}
    />
  );
};

Handling Field Errors

If you recall, the formState object has an errors property that holds all field errors and messages.

You can destructure the errors object from formState, and add an error prop to your InputField component to receive and dynamically render error messages.

// <InputField/> to include 'error' prop
const InputField = ({ type='text', label, registration, error }) => (
  <div>
    <label>
      <span>{label}</span>
    </label>
    <div>
      <input type={type} {...registration}/>
      {error?.message && (
        <span role='alert'>
          {error.message}
        </span>
      )}
    </div>
  </div>
);

The errors property is an object with nested objects of individual fields and their relevant error messages and other data. Something like this:

// form error object
errors: {

  // email field error object
  email: {
    message: '',
    ref: input
    type: 'required'
  },

  // password field error object
  password: {
    message: '',
    ref: input
    type: 'required'
  }
}

Since the key-value of an input field’s error object will always be the field name passed to that when registering that input field, you can directly pass that field’s error object to the error prop. So instead of passing errors to the error prop, pass errors.email

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

const LoginForm = () => {
  const {
    //... other destructured return values
    formState: { errors }
    } = useForm();

  return (
    // rendering the <InputField/> component
    <InputField 
      //... other relevant props 
      error={errors.email}
    />
  );
};

Putting Knowledge into Practice

The essence of this section is to get familiar with the react-hook-form library APIs and develop your sense of working with them.

Let’s build a login form like the one above to make sense of this new knowledge. Our sole focus is incorporating react-hook-form APIs with how we write app forms regularly. We’d achieve this in five high-level steps:

  1. Set up a project and install the relevant dependencies

  2. Write form components

  3. Write the LoginForm component

  4. Call hooks, define effects and event handlers

  5. Register and add the field validations

  • Set up Project

I’d be using Vite to set up this project. We can scaffold a Vite app by opening a terminal and running the Vite command specifying a project name and the desired template — react for the sake of this article.

$ npm create vite@latest tw-login-form --template react

Once the command is successfully executed, change to the tw-login-form directory — cd tw-login-form and run the following terminal commands:

$ npm run install
$ npm run dev

The npm run dev command opens up a development server so you have a live preview of your app. Now, structure the src/ directory in your project to look like this:

src/
  - components/
  - app.jsx
  - index.css
  - main.tsx

Finally, considering the primary focus of this section, we’d be installing just the react-hook-form library. If you need styling libraries and more, sure, go ahead. But for now, all we need is a single command. Run:

npm i --save react-hook-form
  • Build Components

We’d be writing four components in this section — Button, InputField, FieldWrapper, and FormWrapper.

The Button component is important because what’s a form without a call to action? All forms need an action button.

const Button = ({ type='button', disabled, children }) => (
  <button>{children}</button>
);
export default Button;

All form fields need a label. The FieldWrapper component is a reusable wrapper component with an input label for any form field. If we need input fields like the text area or select field, the FieldWrapper component would be reusable.

const FieldWrapper = ({ label, children }) => (
  <div>
    <label>
      <span>{label}</span>
    </label>
    <div>{children}</div>
  </div>
);

export default FieldWrapper;

The InputField component composes the FieldWrapper component to produce a reusable input component.

import FieldWrapper from './field-wrapper';

const InputField = ({ label, type='text', placeholder, registration, error }) => (
  <FieldWrapper label={label}>
    <input
      type={type}
      placeholder={placeholder}
      {...registration}
    />
    {error?.message && (
      <span>{error.message}</span>
    )}
  </FieldWrapper>
);

export default InputField;

Like the FieldWrapper component, the FormWrapper component is a wrapper component for forms that provides a title prop to define the title of every user form.

const FormWrapper = ({ title, children }) => (
  <section>
    <div>
      <h1>{title}</h1>
    </div>
    <div>{children}</div>
  </section>
);

export default FormWrapper;
  • Create Login Form Component

Let’s create a LoginForm component to contain all react-hook form logic like calling hooks, performing side effects, and defining event handlers. We’d then use apply all related logic in the return statement.

First, import all relevant hooks and components from react, react-hook-form and component directories.

import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import FormWrapper from './form-wrapper';
import InputField from './input-field';
import Button from './button';
  • Call Hooks, Define Side Effects and Handlers

After defining the LoginForm component, we should call the useForm hook from the react-hook-form library to get all the methods and data we need to configure and manage our form.

Remember our configuration object — the optional object argument we can pass when invoking the useForm hook? As shown below, in the LoginForm component, I pass the optional configuration object to configure the validation mode for my form fields. I set the value to onChange to trigger validation every time there’s a changeevent on an input field.

Also, because we want our form fields to be reset after the form is submitted, we write a side effect to reset the login form once the isSubmitSuccessful boolean becomes true.

const LoginForm = () => {
  const {
    register,
    reset,
    handleSubmit,
    formState: { isValid, isSubmitting, isSubmitSuccessful, errors }
  } = useForm({ mode: 'onChange' });

   useEffect(() => {
    reset();
    }, [isSubmitSuccessful, reset]);

  const submitHandler = (data) => {
    // POST data to api
    console.log('POST': + data);
  } 

  // ...return statement
}
  • Register Fields and Add Validation

Having set up all methods, handlers and values in the LoginForm UI, let’s pass the submitHandler to handleSubmit called in the onSubmit event prop on the form element.

Subsequently, let’s invoke the register method with the input field name and validation rules as arguments in the registration props.

We want it to be impossible to submit the login form if it is being submitted or if the login form has invalid fields. This is tracked by the isValid and isSubmitting values from the formState object. So we pass a conditional to our disabled prop, and the value the condition evaluates to determines the disabled state of the button.

const LoginForm() => {
   return (
     <FormWrapper title='Login'>
       <form onSubmit={handleSubmit(submitHandler)}>
         <div>
           <InputField 
              label="email"
              placeholder="Enter your email address"
              type="email"
              registration={register("email", {
                required: true,
                pattern: {
                  value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
                  message: "Email address is not valid",
                },
               })}
                 error={errors.email}
             />
             <InputField 
               label="email"
               placeholder="Enter your email address"
               type="email"
               registration={register("email", {
                 required: true,
                 pattern: {
                   value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
                   message: "Email address is not valid",
                 },
                })}
                error={errors.email}
              />
            <div>
              <Button
               type='submit'
               disabled={!isValid || isSubmitting}
              >
               {Login to your account}
              </Button>
            </div>
           </div>
         </form>
      </FormWrapper>
    );
};

The last thing we should do is import our LoginForm — a JSX element and invoke it in our top-level App component. In your project, it might be your auth/login.jsx page, but for the sake of this article, the LoginForm will be rendered on the root page.

import LoginForm from 'components/login-form';

const App = () => <LoginForm/>
export default App;

Wrapping Up

React-hook-form offers reasonable APIs for working with forms and managing form states in our apps. In a time where React apps are plagued with state management, I find a library that offers a way out of managing form states to be very useful.

All the code for this article can be found in this Codesandbox, where I use TypeScript, TailwindCSS and a couple of other dependencies for the project: