In this guide, you’ll learn how to validate any form with React-Hook-Form, Material UI v5, React, Zod, and TypeScript. The form validation will be in two parts: In the first part, we will code all the form validation logic in one file and in the second part, we will move the TextField component into a new file and utilise useFormContext hook and FormProvider component provided by React Hook Form to make the form context available to the component.
With the implementation with useFormContext and FormProvider, you can move the CheckBox component also into a different file.
You can also read:
- How to Customize Material-UI Rating Component with React and TypeScript
- Top 21 VS Code Shortcuts Every Programmer Should Master
React Hook Form, Material UI v5, TypeScript, React and Zod Form Validation Overview
In this crash course, you’ll learn how to write schema validation with Zod, infer the TypeScript type from the schema, and then use a @hookform/resolver/zod in the useForm hook provided by React-Hook-Form and finally use TextField, CheckBox, LoadingButton components provided by MUI v5.
Below is an overview of the schema validation options we will provide to Zod.
- Name: required, must be less than 100 characters
- Email: required, must be a valid email address
- Password: required, must be between 8 and 32 characters
- Confirm Password: required, must be equal to the password
- Terms and Condition: required, must be true
Below are the errors that will be displayed when the user doesn’t fill any of the Input fields and clicks on the submit button.
In the screenshot below, the user has provided all the fields but the email is not valid, the password is not more than 8 characters and lastly, the passwords do not match.
We show the appropriate errors when the user fails to meet the requirement of the validation schema.
Below is what the form will look like when the user provides all the required fields correctly.
Technologies Used
Setup Project
I assume you already have a React boilerplate project and your goal is to implement form validation with React Hook Form and TypeScript.
To begin, we need to fetch all the dependencies and install them in our project.
Install React Hook Form, Zod and Resolver
Open your terminal and run this command to install React Hook Form, Zod and @hooform/resolvers
yarn add react-hook-form zod @hookform/resolvers
Setup Material UI v5 with React
Setting up Material UI with TypeScript and React is a little challenging. Here is a blog post I wrote detailing the various steps to set up MUI and React correctly.
How to Setup Material-UI v5 with React JS and TypeScript
Run the code below to install material UI and its dependencies.
# // with npm
npm install @mui/material @emotion/react @emotion/styled
# // with yarn
yarn add @mui/material @emotion/react @emotion/styled
After following the above instructions correctly, your package.json should look somewhat like this.
{
"dependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@hookform/resolvers": "^2.8.8",
"@mui/icons-material": "^5.6.1",
"@mui/lab": "^5.0.0-alpha.77",
"@mui/material": "^5.6.1",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.0.1",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.4.1",
"@types/node": "^16.11.27",
"@types/react": "^18.0.5",
"@types/react-dom": "^18.0.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-hook-form": "^7.29.0",
"react-scripts": "5.0.1",
"typescript": "^4.6.3",
"web-vitals": "^2.1.4",
"zod": "^3.14.4"
}
}
Form Validation with Material UI v5 and React Hook Form v7
Next, create a new file called register.tsx
and import this file into your App.tsx
file.
Defining the Schema with Zod and TypeScript Type
Before we start defining the schema of the form, we need to import these libraries into the register.tsx
file.
import {
Box,
FormControlLabel,
FormGroup,
FormHelperText,
TextField,
Typography,
} from '@mui/material';
import { useForm, SubmitHandler } from 'react-hook-form';
import { literal, object, string, TypeOf } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useState } from 'react';
import { LoadingButton } from '@mui/lab';
import Checkbox from '@mui/material/Checkbox';
Now, it’s time to define the schema validation rules with Zod and include this schema into the zodResolver in the useForm hook.
const registerSchema = object({
name: string()
.nonempty('Name is required')
.max(32, 'Name must be less than 100 characters'),
email: string().nonempty('Email is required').email('Email is invalid'),
password: string()
.nonempty('Password is required')
.min(8, 'Password must be more than 8 characters')
.max(32, 'Password must be less than 32 characters'),
passwordConfirm: string().nonempty('Please confirm your password'),
terms: literal(true, {
invalid_type_error: 'Accept Terms is required',
}),
}).refine((data) => data.password === data.passwordConfirm, {
path: ['passwordConfirm'],
message: 'Passwords do not match',
});
Infer the Schema to Generate the TypeScript Type
Next, we need to create a TypeScript type for the schema by using the TypeOf function that comes with Zod.
One option is to create an interface and define all the fields inside the schema but it’s just a waste of time.
The TypeOf function accepts a generic which is the type of our schema.
type RegisterInput = TypeOf<typeof registerschema>;
Adding Zod as a Resolver to React-Hook-Form useForm Hook
Now, we need to call the useForm hook then provide it with the Zod schema we defined above and destructure all the necessary methods we need from the useForm hook.
Also, the useForm hook is a generic function so we need to provide it with the inferred type we generated from the schema.
const {
register,
formState: { errors, isSubmitSuccessful },
reset,
handleSubmit,
} = useForm<registerinput>({
resolver: zodResolver(registerSchema),
});
The useForm hook returns an object containing some useful methods:
- register: This function allows you to register an input or select element and apply validation rules to React Hook Form.
- control: This object contains methods for registering components into React Hook Form. Note: You are not allowed to access those methods directly.
- reset: This method resets the entire form state, fields reference, and subscriptions
- formState: It’s an object that contains information about the entire form state
- handleSubmit: This method receives the form data if form validation is successful
Resetting the Form after with reset function and useEffect
Next, we need to clear the input fields whenever the form has been successfully submitted.
With the help of useEffect and the reset method from React Hook Form, we can do just that by providing the useEffect hook with a dependency of the isSubmitSuccessful formState.
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
}, [isSubmitSuccessful, reset]);
Defining the Form Submit Handler
Now, let’s declare our onSubmitHandler and give it a type of SubmitHandler. The SubmitHandler is also a generic function so we need to provide it with the inferred type.
const onSubmitHandler: SubmitHandler<registerinput> = (values) => {
console.log(values);
};
React Hook Form Validation with Material UI v5 and TypeScript Complete Code
Below is the complete form validation with React Hook Form, Zod, TypeScript, Material UI and React.
import {
Box,
FormControlLabel,
FormGroup,
FormHelperText,
TextField,
Typography,
} from '@mui/material';
import { useForm, SubmitHandler } from 'react-hook-form';
import { literal, object, string, TypeOf } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useState } from 'react';
import { LoadingButton } from '@mui/lab';
import Checkbox from '@mui/material/Checkbox';
const registerSchema = object({
name: string()
.nonempty('Name is required')
.max(32, 'Name must be less than 100 characters'),
email: string().nonempty('Email is required').email('Email is invalid'),
password: string()
.nonempty('Password is required')
.min(8, 'Password must be more than 8 characters')
.max(32, 'Password must be less than 32 characters'),
passwordConfirm: string().nonempty('Please confirm your password'),
terms: literal(true, {
invalid_type_error: 'Accept Terms is required',
}),
}).refine((data) => data.password === data.passwordConfirm, {
path: ['passwordConfirm'],
message: 'Passwords do not match',
});
type RegisterInput = TypeOf<typeof registerschema>;
const RegisterPage = () => {
const [loading, setLoading] = useState(false);
const {
register,
formState: { errors, isSubmitSuccessful },
reset,
handleSubmit,
} = useForm<registerinput>({
resolver: zodResolver(registerSchema),
});
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSubmitSuccessful]);
const onSubmitHandler: SubmitHandler<registerinput> = (values) => {
console.log(values);
};
console.log(errors);
return (
<Box sx={{ maxWidth: '30rem' }}>
<Typography variant='h4' component='h1' sx={{ mb: '2rem' }}>
Register
</Typography>
<Box
component='form'
noValidate
autoComplete='off'
onSubmit={handleSubmit(onSubmitHandler)}
>
<TextField
sx={{ mb: 2 }}
label='Name'
fullWidth
required
error={!!errors['name']}
helperText={errors['name'] ? errors['name'].message : ''}
{...register('name')}
/>
<TextField
sx={{ mb: 2 }}
label='Email'
fullWidth
required
type='email'
error={!!errors['email']}
helperText={errors['email'] ? errors['email'].message : ''}
{...register('email')}
/>
<TextField
sx={{ mb: 2 }}
label='Password'
fullWidth
required
type='password'
error={!!errors['password']}
helperText={errors['password'] ? errors['password'].message : ''}
{...register('password')}
/>
<TextField
sx={{ mb: 2 }}
label='Confirm Password'
fullWidth
required
type='password'
error={!!errors['passwordConfirm']}
helperText={
errors['passwordConfirm'] ? errors['passwordConfirm'].message : ''
}
{...register('passwordConfirm')}
/>
<FormGroup>
<FormControlLabel
control={<Checkbox required />}
{...register('terms')}
label={
<Typography color={errors['terms'] ? 'error' : 'inherit'}>
Accept Terms and Conditions
</Typography>
}
/>
<FormHelperText error={!!errors['terms']}>
{errors['terms'] ? errors['terms'].message : ''}
</FormHelperText>
</FormGroup>
<LoadingButton
variant='contained'
fullWidth
type='submit'
loading={loading}
sx={{ py: '0.8rem', mt: '1rem' }}
>
Register
</LoadingButton>
</Box>
</Box>
);
};
export default RegisterPage;
From the code snippets above, you can see we imported a couple of MUI components and also used a LoadingButton from the MUI lab.
When we submit the form to a backend server, we need to provide the loading state to the LoadingButton so that the user knows the form is been submitted.
Form Validation with React-Hook-Form FormProvider and Controller
This is the second way of validating a form with React-Hook-Form, Zod, ReactJS, and Typescript using FormProvider and Controller.
Create a New FormInput Component
I created a new file in the components directory called FormInput.tsx
. Since the FormInput component is going to accept props, I defined the required props with TypeScript alias type and also extended the TextFieldProps from MUI.
I then utilized the useFormContext hook from React Hook Form in order to have access to all the methods returned by the useForm hook.
In brief, the useFormContext hook allows us to have access to the form context in deeply nested structures where it becomes inconvenient to pass the context as a prop.
import { TextField, TextFieldProps } from '@mui/material';
import { FC } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
type IFormInputProps = {
name: string;
} & TextFieldProps;
const FormInput: FC<iforminputprops> = ({ name, ...otherProps }) => {
const {
control,
formState: { errors },
} = useFormContext();
return (
<controller control={control} name={name} defaultvalue="" render={({ field })=> (
<TextField
{...otherProps}
{...field}
error={!!errors[name]}
helperText={errors[name] ? errors[name].message : ''}
/>
)}
/>
);
};
export default FormInput;
Note: You need to provide the Controller a defaultValue else you will get errors in the console.
Complete Code for React Hook Form FormProvider and Controller with TypeScript and React
In the code snippets below, I wrapped the FormProvider around the form and spread the methods object returned by useForm hook so that useFormContext can have access to the form context.
import {
Box,
FormControlLabel,
FormGroup,
FormHelperText,
Typography,
} from '@mui/material';
import { useForm, SubmitHandler, FormProvider } from 'react-hook-form';
import { literal, object, string, TypeOf } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useState } from 'react';
import { LoadingButton } from '@mui/lab';
import Checkbox from '@mui/material/Checkbox';
import FormInput from '../components/FormInput';
const registerSchema = object({
name: string()
.nonempty('Name is required')
.max(32, 'Name must be less than 100 characters'),
email: string().nonempty('Email is required').email('Email is invalid'),
password: string()
.nonempty('Password is required')
.min(8, 'Password must be more than 8 characters')
.max(32, 'Password must be less than 32 characters'),
passwordConfirm: string().nonempty('Please confirm your password'),
terms: literal(true, {
invalid_type_error: 'Accept Terms is required',
}),
}).refine((data) => data.password === data.passwordConfirm, {
path: ['passwordConfirm'],
message: 'Passwords do not match',
});
type RegisterInput = TypeOf<typeof registerschema>;
const RegisterPage2 = () => {
const [loading, setLoading] = useState(false);
const methods = useForm<registerinput>({
resolver: zodResolver(registerSchema),
});
const {
reset,
handleSubmit,
register,
formState: { isSubmitSuccessful, errors },
} = methods;
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSubmitSuccessful]);
const onSubmitHandler: SubmitHandler<registerinput> = (values) => {
console.log(values);
};
console.log(errors);
return (
<Box sx={{ maxWidth: '30rem' }}>
<Typography variant='h4' component='h1' sx={{ mb: '2rem' }}>
Register
</Typography>
<FormProvider {...methods}>
<Box
component='form'
noValidate
autoComplete='off'
onSubmit={handleSubmit(onSubmitHandler)}
>
<FormInput
name='name'
required
fullWidth
label='Name'
sx={{ mb: 2 }}
/>
<FormInput
name='email'
required
fullWidth
label='Email Address'
type='email'
sx={{ mb: 2 }}
/>
<FormInput
name='password'
required
fullWidth
label='Password'
type='password'
sx={{ mb: 2 }}
/>
<FormInput
name='passwordConfirm'
required
fullWidth
label='Confirm Password'
type='password'
sx={{ mb: 2 }}
/>
<FormGroup>
<FormControlLabel
control={<Checkbox required />}
{...register('terms')}
label={
<Typography color={errors['terms'] ? 'error' : 'inherit'}>
Accept Terms and Conditions
</Typography>
}
/>
<FormHelperText error={!!errors['terms']}>
{errors['terms'] ? errors['terms'].message : ''}
</FormHelperText>
</FormGroup>
<LoadingButton
variant='contained'
fullWidth
type='submit'
loading={loading}
sx={{ py: '0.8rem', mt: '1rem' }}
>
Register
</LoadingButton>
</Box>
</FormProvider>
</Box>
);
};
export default RegisterPage2;
Conclusion
In this article, we looked at how you can validate a form with React Hook Form, Zod, Material UI, React, and TypeScript.
There are more functionalities you can build upon the knowledge gained from this article. You can include more MUI v5 components like Select elements.