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.
The following code example is an improved version by leveraging ARIA.
import React from"react";import{ useForm }from"react-hook-form";exportdefaultfunctionApp(){const{ register, handleSubmit,formState:{ errors }}=useForm();constonSubmit=(data)=> console.log(data);return(<form onSubmit={handleSubmit(onSubmit)}><label htmlFor="name">Name</label>{/* use aria-invalid to indicate field contain error */}<input
id="name"
aria-invalid={errors.name ?"true":"false"}{...register('name',{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
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).
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.
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:
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 much easier.
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 optimise our App if required via the example below.
Note: Using React Hook Form's Devtools alongside FormProvider can cause performance issues in some situations. Before diving deep in performance optimizations, consider this bottleneck first.
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 changedconst NestedInput =memo(({ register,formState:{ isDirty }})=>(<div><input {...register("test")}/>{isDirty &&<p>This field is dirty</p>}</div>),(prevProps, nextProps)=>
prevProps.formState.isDirty === nextProps.formState.isDirty
);exportconstNestedInputContainer=({ children })=>{const methods =useFormContext();return<NestedInput {...methods}/>;};exportdefaultfunctionApp(){const methods =useForm();constonSubmit=data=> console.log(data);
console.log(methods.formState.isDirty);// make sure formState is read before render to enable the Proxyreturn(<FormProvider {...methods}><form onSubmit={methods.handleSubmit(onSubmit)}><NestedInputContainer /><input type="submit"/></form></FormProvider>);}
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 them both with validation.
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
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.
Testing is very important because it prevents your code from having bugs or mistakes, and guarantees code safety when refactoring 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 uses MutationObserver to detect inputs, and to get unmounted from the DOM.
module.exports ={setupFilesAfterEnv:["<rootDir>/setup.js"]// or .ts for TypeScript App// ...other settings};
Step 2: Create login form.
We have set the role attribute accordingly. These attributes are helpful for when you write tests, and they improve accessibility. For more information, you can refer to the testing-library documentation.
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"));awaitwaitFor(()=>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("");});});
Resolving act warning during test
If you test a component that uses react-hook-form, you might run into a warning like this, even if you didn't write any asynchronous code for that component:
Warning: An update to MyComponent inside a test was not wrapped in act(...)
import React from"react";import{ render, screen, act }from"@testing-library/react";import App from"./App";describe("App",()=>{it("should have a submit button",()=>{render(<App />);expect(screen.getByText("SUBMIT")).toBeInTheDocument();});});
In this example, there is a simple form without any apparent async code, and the test merely renders the component and tests for the presence of a button. However, it still logs the warning about updates not being wrapped in act().
This is because react-hook-form internally uses asynchronous validation handlers. In order to compute the formState, it has to initially validate the form, which is done asynchronously, resulting in another render. That update happens after the test function returns, which triggers the warning.
To solve this, wrap your render() calls in await act(async () => {}):
import React from"react";import{ render, screen, act }from"@testing-library/react";import App from"./App";describe("App",()=>{it("should have a submit button",async()=>{awaitact(async()=>{render(<App />)});expect(screen.getByText("SUBMIT")).toBeInTheDocument();});});
Transform and Parse
The native input returns the value in string format unless invoked with valueAsNumber or valueAsDate, you can read more under this section. However, it's not perfect, we still have to deal with isNaN or null values. So it's better to leave the transform at the custom hook level. In the following example, we are using the Controller to include the functionality of the transform value's input and output. You can also achieve a similar result with a custom register.