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
The UseForm Hook
Adapting to Modularity in React Components
Putting Knowledge Into Practice
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 thesubmitHandler
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 thesubmitHandler
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:
Set up a project and install the relevant dependencies
Write form components
Write the
LoginForm
componentCall hooks, define effects and event handlers
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: