Making forms in React has always been hard. Handling form state, validation, and errors makes your codebase more complicated. With a focus on performance, React Hook Form fixes these problems by cutting down on re-renders and making form management easier.
This guide tells you everything you need to know about React Hook Form. You will learn how to install, use, and customise wrapper components, as well as how to use Material-UI with them.
Why You Should Use React Hook Form
When you use React to handle forms the old way, you have to keep track of the state of each input field. This way of doing things causes performance problems and code that is too big. React Hook Form does things differently by using uncontrolled components and refs.
The library supports HTML standard validation. It has great support for TypeScript. It makes the bundle smaller by not having any dependencies. These things make React Hook Form the best choice for modern React apps.
Performance tests show that React Hook Form is better than other options. When you type something in Formik or other libraries, they re-render. React Hook Form makes sure that only certain components re-render. Even with a lot of fields, your forms still work.
The library is about 8KB when it is minified and gzipped. It doesn't depend on anything else, which keeps the bundle size down. The API surface is small and easy to understand. It only takes hours for new developers to learn it, not days.
Installation
Using npm:
npm install react-hook-form
Using yarn:
yarn add react-hook-form
Install the necessary packages for Material-UI integration:
npm install @mui/material @emotion/react @emotion/styled
For checking with the validator library:
npm install validator
npm install -D @types/validator
Project Architecture
To keep your forms directory better organised, do the following:
src/
├── components/
│ └── Forms/
│ ├── Form/
│ │ └── Form.tsx
│ └── FormInput/
│ └── FormInput.tsx
├── config/
│ ├── form-models.config.ts
│ └── form-names.config.ts
└── utils/
└── static/
└── FormValidator.ts
Making a Wrapper for a Custom Form
The custom form part gets rid of boilerplate codes. It automatically wraps useForm and FormProvider. React context gives every child component access to form methods.
This pattern cuts down on code that is repeated throughout your app. You only have to define the structure of the form once, and then you can use it anywhere. The wrapper takes care of all the plumbing behind the scenes.
import { ReactNode } from 'react';
import { DefaultValues, FieldValues, FormProvider, Mode, UseFormReturn, useForm } from 'react-hook-form';
export type FormSubmitHandler<T extends FieldValues> = (data: T, methods: UseFormReturn<T>) => void | Promise<void>;
interface FormProps<T extends FieldValues> {
children: ReactNode | ((methods: UseFormReturn<T>) => ReactNode);
onSubmit: FormSubmitHandler<T>;
defaultValues?: DefaultValues<T>;
mode?: Mode;
id?: string;
}
export function Form<T extends FieldValues>({
children,
onSubmit,
defaultValues,
mode = 'onBlur',
id,
...rest
}: FormProps<T>) {
const methods = useForm<T>({ mode, defaultValues });
return (
<FormProvider {...methods}>
<form id={id} onSubmit={methods.handleSubmit(data => onSubmit(data, methods))} {...rest}>
{typeof children === 'function' ? children(methods) : children}
</form>
</FormProvider>
);
}
The submit handler gets both the form values and the methods object. This lets you handle errors on the server side with methods.setError(). The render props pattern makes formState available for conditional rendering, such as loading states and validation feedback.
Some important props:
- defaultValues – set the initial state of the form
- mode – set the time for validation (
onBlur,onChange, orall) - id – connect external submit buttons
Making the FormInput Component
The FormInput component wraps the MUI TextField in a React Hook Form Controller. It gets control from context on its own. You never pass it as a prop by hand.
Messages about errors show up below the input field. The component connects the form state to the UI. This abstraction makes your form code easier to read and understand.
import { Controller, FieldValues, Path, useFormContext } from 'react-hook-form';
import { TextField, TextFieldProps } from '@mui/material';
interface FormInputProps<T extends FieldValues> extends Omit<TextFieldProps, 'name'> {
name: Path<T>;
validate?: (value: any) => true | string;
renderInput?: (props: { field: any; error?: string }) => ReactNode;
}
export function FormInput<T extends FieldValues>({ name, validate, renderInput, ...props }: FormInputProps<T>) {
const {
control,
formState: { errors },
} = useFormContext<T>();
return (
<Controller
name={name}
control={control}
rules={{ validate }}
render={({ field }) => {
if (renderInput) {
return renderInput({ field, error: errors[name]?.message as string });
}
return <TextField {...field} {...props} error={!!errors[name]} helperText={errors[name]?.message as string} />;
}}
/>
);
}
The renderInput prop lets you customise how DatePickers, Autocomplete, or RadioGroup components are displayed. You can give the component any MUI TextField props directly. They spread out over the input element below them.
You can use dot notation for nested objects with the name prop. Use paths like address.city or user.profile.name. The component fixes nested errors in the right way.
FormValidator: Centralised Validation
It's hard to keep track of validation logic that is spread out across different parts. This problem can be fixed by using a centralised FormValidator class. Write validation functions once and use them all over your app.
Every validator returns either true for valid input or a string with an error message. This pattern works directly with React Hook Form's validation system. The all method links together a lot of validators.
import Validator from 'validator';
export class FormValidator {
static all(...fns: Array<(value: any) => true | string>) {
return (value: any) => fns.map(fn => fn(value)).find(e => e !== true) || true;
}
static isNotEmpty = (value: string) => (Validator.isEmpty(value || '') ? 'Required' : true);
static isNotEmptyArray = (value: any[]) => ((value?.length ?? 0) > 0 ? true : 'Required');
static isValidEmail = (value: string) => (Validator.isEmail(value || '') ? true : 'Invalid email address');
static minLength = (min: number) => (value: string) =>
(value?.length ?? 0) >= min ? true : `At least ${min} characters`;
static maxLength = (max: number) => (value: string) =>
(value?.length ?? 0) <= max ? true : `Maximum ${max} characters`;
static isWholeNumber = (value: string) => (/^\d+$/.test(value || '') ? true : 'Must be a whole number');
}
The validator library has validation functions that have been tested in real life. It takes care of edge cases that you might miss with your own regex patterns. Install it with React Hook Form to make sure it works well.
Usage Examples
// Single validation
<FormInput name="email" validate={FormValidator.isValidEmail} />
// Combined validations
<FormInput
name="email"
validate={FormValidator.all(
FormValidator.isNotEmpty,
FormValidator.isValidEmail
)}
/>
// Password with length requirements
<FormInput
name="password"
type="password"
validate={FormValidator.all(
FormValidator.isNotEmpty,
FormValidator.minLength(8),
FormValidator.maxLength(128)
)}
/>
// Optional field - no validate prop
<FormInput name="nickname" />
As your app grows, add more validators. The structure of the class helps them stay organised and easy to find. Team members can always find validation rules in the same place.
Form Types
In a central config file, define TypeScript interfaces:
// src/config/form-models.config.ts
export type LoginFormValues = {
email: string;
password: string;
};
export interface CreateUserFormValues {
name: string;
surname: string;
email: string;
phoneNumber: string;
}
Store constants for external submit button form IDs:
// src/config/form-names.config.ts
export const LOGIN_FORM = 'login-form';
export const CREATE_USER_FORM = 'create-user-form';
Basic Form Usage
A full login form shows how all the parts work together. Start with a defaultValues object that is the same type as your TypeScript type. This makes sure that the type is safe from the start.
The form wrapper gets default values and a handler for when the form is submitted. Child components can get to the form state through render props. The isSubmitting flag stops API calls from being sent twice.
import { Form, FormInput } from '@/components/Forms';
import { LoginFormValues } from '@/config/form-models.config';
import { AuthService } from '@/services';
const defaultValues: LoginFormValues = {
email: '',
password: '',
};
const Login = () => {
const handleSubmit = async (values: LoginFormValues, methods: UseFormReturn<LoginFormValues>) => {
const { payload } = await AuthService.login(values);
if (!payload) {
methods.setError('password', { message: 'Invalid credentials' });
}
};
return (
<Form<LoginFormValues> defaultValues={defaultValues} onSubmit={handleSubmit}>
{({ formState: { isSubmitting } }) => (
<>
<FormInput name="email" label="Email" validate={FormValidator.isValidEmail} />
<FormInput name="password" label="Password" type="password" validate={FormValidator.isNotEmpty} />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Log in'}
</button>
</>
)}
</Form>
);
};
setError() shows errors on the server side. The method takes a field name and an error configuration. The error message is shown below the input that caused it. This method makes it easy to validate both the client and the server.
Wrap error messages in translation functions for projects that need to be internationalised. The pattern works perfectly with i18next and other libraries like it.
Forms with Modals and External Buttons
In modal dialogues, the submit buttons are usually in a footer, which is outside of the form element. The submit button must be inside the form tag for HTML forms to work. The id prop fixes this problem in a smart way.
Put an ID on the form part. Use this ID in the form attribute of the button. The browser connects them on its own. You can use this with any modal library or custom code.
import { CREATE_USER_FORM } from '@/config/form-names.config';
// In the modal body
<Form<CreateUserFormValues>
id={CREATE_USER_FORM}
defaultValues={defaultValues}
onSubmit={handleSubmit}
>
<FormInput name="name" label="Name" />
<FormInput name="email" label="Email" />
</Form>
// In the modal footer (outside the form)
<button type="submit" form={CREATE_USER_FORM}>
Create User
</button>
Put the IDs of the forms in a constants file. This stops typos and makes it possible to refactor. Bring in the constant in both the Form component and the place where the submit button is. The pattern works for apps with dozens of modal forms.
Rendering Custom Inputs
TextField can handle most types of input, but apps need different types of input. You need to handle DatePickers, autocomplete dropdowns, and RadioGroups in a special way. The renderInput prop takes care of this need.
The render function gets the field object and any errors that were found during validation. Put field properties on your own component. To update the form state correctly, handle the onChange callback.
// Autocomplete dropdown
<FormInput
name="role"
renderInput={({ field, error }) => (
<Autocomplete
value={field.value}
onChange={(_, val) => field.onChange(val)}
options={roleOptions}
renderInput={(params) => (
<TextField
{...params}
label="Role"
error={!!error}
helperText={error}
/>
)}
/>
)}
/>
// Date picker
<FormInput
name="birthDate"
renderInput={({ field, error }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
slotProps={{
textField: {
error: !!error,
helperText: error
}
}}
/>
)}
/>
// Radio group
<FormInput
name="gender"
renderInput={({ field }) => (
<RadioGroup value={field.value} onChange={field.onChange}>
<FormControlLabel value="male" control={<Radio />} label="Male" />
<FormControlLabel value="female" control={<Radio />} label="Female" />
</RadioGroup>
)}
/>
The field object has value, onChange, onBlur, and ref. value and onChange are all that most components need. For timing validation, complex parts may use onBlur. The pattern stays the same for all types of input.
Dot Notation for Nested Fields
Nested data structures are needed for complex forms. There are sub-objects in shipping addresses, user profiles, and configuration objects. Dot notation in field names lets React Hook Form handle nesting.
Use the nested structure to set your default values. In the name prop, use dot paths. The data that was sent keeps the same shape. No need to change anything by hand before making API calls.
interface AddressFormValues {
name: string;
address: {
street: string;
city: string;
zipCode: string;
};
}
const defaultValues: AddressFormValues = {
name: '',
address: {
street: '',
city: '',
zipCode: ''
}
};
// In your form component
<FormInput name="name" label="Full Name" />
<FormInput name="address.street" label="Street" />
<FormInput name="address.city" label="City" />
<FormInput name="address.zipCode" label="ZIP Code" />
The FormInput component automatically fixes errors that are nested. Get to deeper levels by adding more dots. Paths like user.profile.settings.notifications work fine. The pattern can be used at any level of nesting that your data model needs.
Using useFormContext
Form access is needed by child components deep in the tree. Prop drilling form methods create messy code. The useFormContext hook fixes this problem in a clean way.
You can call the hook from any component that is inside FormProvider. It gives back all the form methods, such as watch, setValue, getValues, and reset. For proper autocomplete, type the hook with your form values interface.
import { useFormContext } from 'react-hook-form';
interface OrderFormValues {
customerId: string;
locationId: string;
}
const CustomerLocationInput = () => {
const { watch, setValue } = useFormContext<OrderFormValues>();
const customerId = watch('customerId');
const handleChange = (newId: string) => {
setValue('customerId', newId);
setValue('locationId', ''); // Reset dependent field
};
return <CustomerSelect value={customerId} onChange={handleChange} />;
};
This pattern works well with cascading dropdowns. Reset the child fields when the parent selection changes. The setValue function changes certain fields without changing others. To create complex dependent logic, keep an eye on more than one field.
Put reusable input groups into their own components. They get to the form state through context. The parent form is still easy to use. This pattern makes code organisation a lot better.
Handling Server-Side Errors
Client validation finds obvious mistakes. Server validation takes care of business rules and database limits. Show server errors in the same place as client errors. No matter where the error comes from, users always get the same feedback.
The setError method adds errors to the code. After you get API responses, call it. The error message shows up below the input field that caused it. Fix the field and send it in again to clear the error.
const handleSubmit = async (values: CreateUserFormValues, methods: UseFormReturn<CreateUserFormValues>) => {
const { payload, errors } = await UserService.create(values);
if (!payload) {
// Field-specific errors
if (errors?.email) {
methods.setError('email', {
type: 'server',
message: errors.email,
});
}
if (errors?.username) {
methods.setError('username', {
type: 'server',
message: 'Username taken',
});
}
return;
}
// Success handling
showToast('User created successfully');
};
Errors that aren't specific to a field should look different. You can use toast notifications or an error banner at the form level. The root error key in React Hook Form takes care of messages at the form level. Pick the method that fits your UI patterns.
Form State for User Experience
The formState object has useful information in it. Use it to improve the user experience by adding loading states, warnings for unsaved changes, and conditional button states.
- isSubmitting – becomes
truewhen the submission is async. Turn off inputs and buttons so that people can't make the same request twice. Use loading indicators to show how far along you are. - isDirty – checks to see if the values in the form are different from the defaults. Let users know before they leave about changes that haven't been saved. Only let users save changes when there are changes.
- isValid – shows the current state of validation. Use it with other flags to get exact control over the button.
<Form<UserFormValues> defaultValues={defaultValues} onSubmit={handleSubmit}>
{({ formState: { isSubmitting, isDirty, isValid } }) => (
<>
<FormInput name="name" label="Name" />
<FormInput name="email" label="Email" />
<button type="submit" disabled={isSubmitting || !isDirty || !isValid}>
{isSubmitting ? 'Saving...' : 'Save'}
</button>
<button type="button" onClick={() => methods.reset()} disabled={!isDirty}>
Reset
</button>
</>
)}
</Form>
In onChange mode, the form keeps checking itself. Access to touched and dirty states for each field for more detailed UI feedback.
Best Practices
- Always set the
defaultValuesto the right TypeScript type - For the best user experience, use
mode="onBlur" - Type your
useFormContextcalls - Use
FormValidator.all()to combine validators - Use the form ID for buttons that submit modals
- Use
setError()to deal with server errors - Use
isSubmittingto check if the page is loading - Keep track of
isDirtyfor dialogues with changes that haven't been saved yet







