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.

Form Validation with React Hook Form, Material UI, React and TypeScript

With the implementation with useFormContext and FormProvider, you can move the CheckBox component also into a different file.

You can also read:

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.

react hook form mui v5 zod register form with errors

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.

react hook form mui v5 zod register form with partial error

Below is what the form will look like when the user provides all the required fields correctly.

react hook form mui v5 zod register form

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.