Skip to content

Advanced

Build complex and accessible forms with React Hook Form.

Accessibility (A11y)

React Hook Form has support for native form validation, which lets you validate inputs with your own rules. Since most of us have to build forms with custom designs and layouts, it is our responsibility to make sure those are accessible (A11y).

The following code example works as intended for validation; however, it can be improved for accessibility.

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

export default function App() {
  const { register, handleSubmit, errors } = useForm();
  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label for="name">Name</label>
      <input type="text" id="name" ref={register({ required: true, maxLength: 30 })} />
      {errors.name && errors.name.type === "required" && <span>This is required</span>}
      {errors.name && errors.name.type === "maxLength" && <span>Max length exceeded</span> }
      <input type="submit" />
    </form>
  );
}

The following code example is an improved version by leveraging ARIA.

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

export default function App() {
  const { register, handleSubmit, errors } = useForm();
  const onSubmit = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label for="name">Name</label>
      
      {/* use aria-invalid to indicate field contain error */}
      <input
        type="text"
        id="name"
        aria-invalid={errors.name ? "true" : "false"}
        ref={register({ required: true, maxLength: 30 })}
      />
      
      {/* use role="alert" to announce the error message */}
      {errors.name && errors.name.type === "required" && (
        <span role="alert">This is required</span>
      )}
      {errors.name && errors.name.type === "maxLength" && (
        <span role="alert">Max length exceeded</span>
      )}
      
      <input type="submit" />
    </form>
  );
}

After this improvement, the screen reader will say: “Name, edit, invalid entry, This is required.”


Wizard Form / Funnel

In this video tutorial, I have demonstrated the core concept of how to build multiple steps funnel with React Hook Form.

It's pretty common to collect user information through different pages and sections. We recommend using a state management library to store user input through different pages or sections. In this example, we are going to use little state machine as our state management library (you can replace it with redux if you are more familiar with it).

Step 1: Set up your routes and store.

import React from "react";
import { BrowserRouter as Router, Route } from "react-router-dom";
import { StateMachineProvider, createStore } from "little-state-machine";
import Step1 from "./Step1";
import Step2 from "./Step2";
import Result from "./Result";

createStore({
  data: {}
});

export default function App() {
  return (
    <StateMachineProvider>
      <Router>
        <Route exact path="/" component={Step1} />
        <Route path="/step2" component={Step2} />
        <Route path="/result" component={Result} />
      </Router>
    </StateMachineProvider>
  );
}

Step 2: Create your pages, collect and submit the data to the store and push to the next form/page.

import React from "react";
import { useForm } from "react-hook-form";
import { withRouter } from "react-router-dom";
import { useStateMachine } from "little-state-machine";
import updateAction from "./updateAction";

const Step1 = props => {
  const { register, handleSubmit } = useForm();
  const { action } = useStateMachine(updateAction);
  const onSubmit = data => {
    action(data);
    props.history.push("./step2");
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input name="firstName" ref={register} />
      <input name="lastName" ref={register} />
      <input type="submit" />
    </form>
  );
};

export default withRouter(Step1);

Step 3: Make your final submission with all the data in the store or display the resulting data.

import React from "react";
import { useStateMachine } from "little-state-machine";
import updateAction from "./updateAction";

const Step1 = props => {
  const { state } = useStateMachine(updateAction);

  return <pre>{JSON.stringify(state, null, 2)}</pre>;
};

Following the above pattern, you should be able to build a wizard form/funnel to collect user input data from multiple pages.


Smart Form Component

This idea here is that you can easily compose your form with inputs. We are going to create a Form component to automatically collect form data.

import React from "react";
import { Form, Input, Select } from "./Components";

export default function App() {
  const onSubmit = data => console.log(data);

  return (
    <Form onSubmit={onSubmit}>
      <Input name="firstName" />
      <Input name="lastName" />
      <Select name="gender" options={["female", "male", "other"]} />

      <Input type="submit" value="Submit" />
    </Form>
  );
}

Let's have a look what's in each of those components.

Form

The Form component's responsibility is to inject all react-hook-form methods into the child component.

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

export default function Form({ defaultValues, children, onSubmit }) {
  const methods = useForm({ defaultValues });
  const { handleSubmit } = methods;

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {React.Children.map(children, child => {
        return child.props.name
          ? React.createElement(child.type, {
              ...{
                ...child.props,
                register: methods.register,
                key: child.props.name
              }
            })
          : child;
       })}
    </form>
  );
}

Input / Select

Those input components' responsibility is to registering them into react-hook-form.

import React from "react";

export function Input({ register, name, ...rest }) {
  return <input name={name} ref={register} {...rest} />;
}

export function Select({ register, options, name, ...rest }) {
  return (
    <select name={name} ref={register} {...rest}>
      {options.map(value => (
        <option key={value} value={value}>
          {value}
        </option>
      ))}
    </select>
  );
}

With the Form component injecting react-hook-form's props into the child component, you can easily create and compose complex forms in your app.


Field Arrays

This is one of the best features about React Hook Form: instead of importing components (like other libraries) to achieve this functionality, you can leverage your existing HTML markup. The key is within the name attribute. In React Hook Form, the name attribute represents the data structure you want to use.

Note: we have also built a custom hook for complex scenario: useFieldArray.

The following example demonstrates how you can create Field Arrays by manipulating the input name attribute.

Note: if your application requires functionality such as Delete, Insert, Append, Preprend, here is the link for such implementation. with Controller.

import React from "react";
import { useForm, useFieldArray } from "react-hook-form";

function App() {
  const { register, control, handleSubmit, reset, trigger, setError } = useForm({
    // defaultValues: {}; you can populate the fields by this attribute 
  });
  const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({
    control,
    name: "test"
  });
  
  // trigger validation at the field array level
  // useEffect(() => {
  //   if (fields) trigger('test'); // with resolver
  //   if (!fields.length) setError('miniLength, 'at least 1 field array item'); // build-in validator
  // }, [trigger, fields])

  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <ul>
        {fields.map((item, index) => (
          <li key={item.id}>
            <input
              name={`test[${index}].firstName`}
              ref={register()}
              defaultValue={item.firstName} // make sure to set up defaultValue
            />

            <Controller
              as={<input />}
              name={`test[${index}].lastName`}
              control={control}
              defaultValue={item.lastName} // make sure to set up defaultValue
            />

            <button type="button" onClick={() => remove(index)}>Delete</button>
          </li>
        ))}
      </ul>
      <button
        type="button"
        onClick={() => append({ firstName: "appendBill", lastName: "appendLuo" })}
      >
        append
      </button>
      <button
        type="button"
        onClick={() => prepend({ firstName: "prependFirstName", lastName: "prependLastName" })}
      >
        prepend
      </button>
      <input type="submit" />
    </form>
  );
import React, { useState } from "react";
import { useForm } from "react-hook-form";

function createArrayWithNumbers(length) {
  return Array.from({ length }, (_, k) => k + 1);
}

export default function App() {
  const { register, handleSubmit } = useForm();
  const [size, setSize] = useState(1);
  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {createArrayWithNumbers(size).map(index => {
        return (
          <>
            <label htmlFor="firstName">First Name</label>
            <input
              name={`firstName[${index}]`}
              placeholder="first name"
              ref={register({ required: true })}
            />
            
            <label htmlFor="lastName">Last Name</label>
            <input
              name={`lastName[${index}]`}
              placeholder="last name"
              ref={register({ required: true })}
            />
          </>
        );
      })}

      <button type="button" onClick={() => setSize(size + 1)} >
        Add Person
      </button>
      
      <input type="submit" />
    </form>
  );
}

Error Messages

Error messages are visual feedback to our users when there are issues with their inputs. React Hook Form provides an errors object to let you retrieve errors easily. There are several different ways to improve error presentation on the screen.

  • Register

    You can simply pass the error message to register, via the message attribute of the validation rule object, like this:

    <input name="test" ref={register({ maxLength: { value: 2, message: "error message" } })} />

  • Optional Chaining

    The ?. optional chaining operator permits reading the errors object without worrying about causing another error due to null or undefined.

    errors?.firstName?.message

  • Lodash get

    If your project is using lodash, then you can leverage the lodash get function. Eg:

    get(errors, 'firstName.message')


Connect Form

When we are building forms, there are times when our input lives inside of deeply nested component trees, and that's when FormContext comes in handy. However, we can further improve the Developer Experience by creating a ConnectForm component and leveraging React's renderProps. The benefit is you can connect your input with React Hook Form from much easier.

import { FormProvider, useForm, useFormContext } from "react-hook-form";

export const ConnectForm = ({ children }) => {
 const methods = useFormContext();
 
 return children({ ...methods });
};

export const DeepNest = () => (
  <ConnectForm>
    {({ register }) => <input ref={register} name="deepNestedInput" />}
  </ConnectForm>
);

export const App = () => {
  const methods = useForm();
  
  return (
    <FormProvider {...methods} >
      <form>
        <DeepNest />
      </form>
    </FormProvider>
  );
}

FormProvider Performance

React Hook Form's FormProvider is built upon React's Context API. It solves the problem where data is passed through the component tree without having to pass props down manually at every level. This also causes the component tree to trigger a re-render when React Hook Form triggers a state update, but we can still can optimise our App if required via the example below.

import React, { memo } from "react";
import { useForm, FormProvider, useFormContext } from "react-hook-form";

// we can use React.memo to prevent re-render except isDirty state changed
const NestedInput = memo(
  ({ register, formState: { isDirty } }) => (
    <div>
      <input name="test" ref={register} />
      {isDirty && <p>This field is dirty</p>}
    </div>
  ),
  (prevProps, nextProps) =>
    prevProps.formState.isDirty === nextProps.formState.isDirty
);

export const NestedInputContainer = ({ children }) => {
  const methods = useFormContext();

  return <NestedInput {...methods} />;
};

export default function App() {
  const methods = useForm();
  const onSubmit = data => console.log(data);
  console.log(methods.formState.isDirty); // make sure formState is read before render to enable the Proxy

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <NestedInputContainer />
        <input type="submit" />
      </form>
    </FormProvider>
  );
}

Conditional Controlled Component

React Hook Form makes dealing with conditional fields really simple because when you remove the input from the component tree, it will get unregistered automatically. Here is an example for such behavior. However, that's not the same case for controlled components since ref is not been registered, and we can do the following:

  • Toggle the input's viability

  • Building separate form for modal and pop up form

  • Leverage the use of useEffect with custom register

  • Import Controller to wrap your component and let it manage register and unregister

Here are the examples:

import * as React from "react";
import { useForm } from "react-hook-form";
import { ModalForm } from "./ModalForm";

type FormValues = {
  toggle: boolean;
  mail: string;
  ghost: string;
  keepValue: string;
};

export default function App() {
  const [modalFormData, setModalFormData] = React.useState("");
  const { watch, register, setValue, getValues, handleSubmit } = useForm<FormValues>();
  const [showModal, setShowModal] = React.useState(false);
  const { toggle, mail } = watch();
  
  React.useEffect(() => {
    setValue("mail", modalFormData);
  }, [setValue, modalFormData]);

  const onSubmit = (data: FormValues) => {
    console.log(data);
  };

  return (
    <>
      <form onSubmit={handleSubmit(onSubmit)}>
        <input type="checkbox" name="toggle" ref={register} />
        {toggle && (
          <button type="button" onClick={() => setShowModal(!showModal)}>
            Show Modal
          </button>
        )}

        <input name="mail" placeholder="mail" ref={register} />

        <input
          name="keepValue"
          placeholder="keepValue"
          ref={register}
          style={{
            display: toggle ? "block" : "none" // toggle the visbility of an input
          }}
        />

        <input type="submit" />
      </form>

      {/* working with a modal pop up, make sure to create separate form */}
      {showModal && (
        <ModalForm mail={mail} setModalFormData={setModalFormData} />
      )}
    </>
  );
}
import React, { useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import { TextField } from "@material-ui/core";

function App() {
  const { register, handleSubmit, setValue, watch, control } = useForm();
  const name = watch("name");
  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>

      <label>Name:</label>
      <input ref={register} name="name" />

      <label>Conditional Field:</label>
      {name !== "bill" && (
        <Controller
          name="test" 
          as={TextField}
          control={control} 
          defaultValue=""
        />
      )}

      <input type="submit" />
    </form>
  );
}
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";

const CustomInput = React.memo(({ register, unregister, setValue, name }) => {
  useEffect(() => {
    register({ name });
    return () => unregister(name);
  }, [name, register, unregister]);
  
  return <input onChange={e => setValue(name, e.target.value)} />;
});

function App() {
  const { register, unregister, handleSubmit, setValue, watch } = useForm();
  const name = watch("name");
  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h1>Unregister Controlled Component</h1>

      <label>Name:</label>
      <input ref={register} name="name" />

      <label>Conditional Field:</label>
      {name !== "bill" && (
        <CustomInput {...{ register, unregister, setValue, name: "test" }} />
      )}

      <input type="submit" />
    </form>
  );
}

Controlled mixed with Uncontrolled Components

React Hook Form embraces uncontrolled components and is also compatible with controlled components. Most UI libraries are built to support only controlled components, such as Material-UI and Antd Besides, with React Hook Form the re-rendering of controlled component is also optimized. Here is an example that combines controlled and uncontrolled form validation.

import React, { useEffect } from "react";
import { Input, Select, MenuItem } from "@material-ui/core";
import { useForm, Controller } from "react-hook-form";

const defaultValues = {
  select: "",
  input: ""
};

function App() {
  const { handleSubmit, reset, watch, control } = useForm({ defaultValues });
  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        as={
          <Select>
            <MenuItem value={10}>Ten</MenuItem>
            <MenuItem value={20}>Twenty</MenuItem>
          </Select>
        }
        control={control}
        name="select"
        defaultValue={10}
      />
      
      <Input inputRef={register} name="input" />

      <button type="button" onClick={() => reset({ defaultValues })}>Reset</button>
      <input type="submit" />
    </form>
  );
}

import React, { useEffect } from "react";
import { Input, Select, MenuItem } from "@material-ui/core";
import { useForm } from "react-hook-form";

const defaultValues = {
  select: "",
  input: ""
};

function App() {
  const { register, handleSubmit, setValue, reset, watch } = useForm({ defaultValues });
  const selectValue = watch("select");
  const onSubmit = data => console.log(data);

  useEffect(() => {
    register({ name: "select" });
  }, [register]);

  const handleChange = e => setValue("select", e.target.value);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Select value={selectValue} onChange={handleChange}>
        <MenuItem value={10}>Ten</MenuItem>
        <MenuItem value={20}>Twenty</MenuItem>
      </Select>
      <br />
      <Input inputRef={register} name="input" />

      <button type="button" onClick={() => reset({ ...defaultValues })}>Reset</button>
      <input type="submit" />
    </form>
  );
}


Custom Hook with Resolver

You can build a custom hook as a resolver. A custom hook can easily integrate with yup/Joi/Superstruct as a validation method, and to be used inside validation resolver.

  • Define a memoized validation schema (or define it outside your component if you don't have any dependencies)

  • Use the custom hook, by passing the validation schema

  • Pass the validation resolver to the useForm hook

import { useCallback, useMemo } from "react";
import { useForm } from "react-hook-form";

const useYupValidationResolver = validationSchema =>
  useCallback(
    async data => {
      try {
        const values = await validationSchema.validate(data, {
          abortEarly: false
        });

        return {
          values,
          errors: {}
        };
      } catch (errors) {
        return {
          values: {},
          errors: errors.inner.reduce(
            (allErrors, currentError) => ({
              ...allErrors,
              [currentError.path]: {
                type: currentError.type ?? "validation",
                message: currentError.message
              }
            }),
            {}
          )
        };
      }
    },
    [validationSchema]
  );

const validationSchema = useMemo(
  () =>
    yup.object({
      firstName: yup.string().required("Required"),
      lastName: yup.string().required("Required")
    }),
  []
);

const validationResolver = useYupValidationResolver(validationSchema);

const form = useForm({ validationResolver });

Working with virtualized lists

Imagine a scenario where you have a table of data. This table might contain hundreds or thousands of rows, and each row will have inputs. A common practice is to only render the items that are in the viewport, however this will cause issues as the items are removed from the DOM when they are out of view, and re-added. This will cause items to reset to their default values when they re-enter the viewport.

To work around this, you can manually register the fields, and then programmatically set the value of the field.

An example is shown below using react-window.

import React from "react";
import { FormContext, useForm, useFormContext } from "react-hook-form";
import { VariableSizeList as List } from "react-window";

const items = Array.from(Array(1000).keys()).map((i) => ({
  title: "List ${i}",
  quantity: Math.floor(Math.random() * 10),
}));

const WindowedRow = React.memo(({ index, style, data, getValues, setValue }) => {
  const values = getValues();
  const qtyKey = "[${index}].quantity";
  const qty = values[qtyKey];

  return (
    <div>
      <input
        // Rather than ref={register}, we use defaultValue and setValue
        defaultValue={qty}
        onChange={(e) => {
          setValue(
            qtyKey,
            isNaN(Number(e.target.value)) ? 0 : Number(e.target.value)
          );
        }}
      />
    </div>
  );
});

export default React.memo(({ items }) => {
  const formMethods = useForm({ defaultValues: items });
  const onSubmit = (data) => console.log(data);

  // We manually call register here for each field.
  React.useEffect(() => {
    items.forEach((item, idx) => {
      formMethods.register("[${idx}].quantity");
    });
  }, [formMethods, items]);

  return (
    <form onSubmit={formMethods.handleSubmit(onSubmit)}>
      <List
        itemCount={items.length}
        itemSize={() => 50}
        itemData={items}
        {{ ...formMethods }}
      >
        {WindowedRow}
      </List>
      <button type="submit">Submit</button>
    </form>
  );
});

Testing Form

Testing is very important because it preserve code from bugs or mistakes and guarantee code safety when you refactor the codebase.

We recommend using testing-library, because it is simple and tests are more focused on user behavior.

Step 1: Set up your testing environment.

Please install @testing-library/jest-dom with the latest version of jest because react-hook-form use MutationObserver to detect inputs get unmounted from the DOM.

Note: If you are using React Native, you don't need to install @testing-library/jest-dom.

npm install -D @testing-library/jest-dom

Create setup.js to import @testing-library/jest-dom.

Note: If you are using React Native, you need to create setup.js , and define window object.

import "@testing-library/jest-dom";

Finally, you have to update setup.js in jest.config.js to include the file.

module.exports = {
  setupFilesAfterEnv: ["<rootDir>/setup.ts"]
  // ...other settings
};

Step 2: Create login form.

We have set the role attribute accordingly. These attributes are helpful when you will write tests and improve accessibility. For more information, you can refer to the testing-library documentation.

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

export default function App({ login }) {
  const { register, handleSubmit, errors, reset } = useForm();
  const onSubmit = async data => {
    await login(data.email, data.password);
    reset();
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label htmlFor="email">email</label>
      <input
        id="email"
        name="email"
        ref={register({
          required: "required",
          pattern: {
            value: /S+@S+.S+/,
            message: "Entered value does not match email format"
          }
        })}
        type="email"
      />
      {errors.email && <span role="alert">{errors.email.message}</span>}
      <label htmlFor="password">password</label>
      <input
        id="password"
        name="password"
        ref={register({
          required: "required",
          minLength: {
            value: 5,
            message: "min length is 5"
          }
        })}
        type="password"
      />
      {errors.password && <span role="alert">{errors.password.message}</span>}
      <button type="submit">SUBMIT</button>
    </form>
  );
}

Step 3: Write tests.

The following criteria are what we try to cover with the tests:

  • Test submission failure.

    We are using waitFor and find* method to detect submission feedback because handleSubmit method is executed asynchronously.

  • Test validation associated with each inputs.

    We are using *ByRole method when querying different elements because that's how users recognize your UI component.

  • Test successful submission.

import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import App from "./App";

const mockLogin = jest.fn((email, password) => {
  return Promise.resolve({ email, password });
});

describe("App", () => {
  beforeEach(() => {
    render(<App login={mockLogin} />);
  });
  
  it("should display required error when value is invalid", async () => {
    fireEvent.submit(screen.getByRole("button"));

    expect(await screen.findAllByRole("alert")).toHaveLength(2);
    expect(mockLogin).not.toBeCalled();
  });

  it("should display matching error when email is invalid", async () => {
    fireEvent.input(screen.getByRole("textbox", { name: /email/i }), {
      target: {
        value: "test"
      }
    });

    fireEvent.input(screen.getByLabelText("password"), {
      target: {
        value: "password"
      }
    });

    fireEvent.submit(screen.getByRole("button"));

    expect(await screen.findAllByRole("alert")).toHaveLength(1);
    expect(mockLogin).not.toBeCalled();
    expect(screen.getByRole("textbox", { name: /email/i }).value).toBe("test");
    expect(screen.getByLabelText("password").value).toBe("password");
  });

  it("should display min length error when password is invalid", async () => {
    fireEvent.input(screen.getByRole("textbox", { name: /email/i }), {
      target: {
        value: "test@mail.com"
      }
    });

    fireEvent.input(screen.getByLabelText("password"), {
      target: {
        value: "pass"
      }
    });

    fireEvent.submit(screen.getByRole("button"));

    expect(await screen.findAllByRole("alert")).toHaveLength(1);
    expect(mockLogin).not.toBeCalled();
    expect(screen.getByRole("textbox", { name: /email/i }).value).toBe(
      "test@mail.com"
    );
    expect(screen.getByLabelText("password").value).toBe("pass");
  });

  it("should not display error when value is valid", async () => {
    fireEvent.input(screen.getByRole("textbox", { name: /email/i }), {
      target: {
        value: "test@mail.com"
      }
    });

    fireEvent.input(screen.getByLabelText("password"), {
      target: {
        value: "password"
      }
    });

    fireEvent.submit(screen.getByRole("button"));

    await waitFor(() => expect(screen.queryAllByRole("alert")).toHaveLength(0));
    expect(mockLogin).toBeCalledWith("test@mail.com", "password");
    expect(screen.getByRole("textbox", { name: /email/i }).value).toBe("");
    expect(screen.getByLabelText("password").value).toBe("");
  });
});

We Need Your Support

If you find React Hook Form to be useful in your React project, please star to support the repo and contributors ❤

Edit