Effortless Form Input Validation in React with Zod: A Comprehensive Guide

Effortless Form Input Validation in React with Zod: A Comprehensive Guide

React form input validation using Zod and React-hook-form

Introduction

Input validation is an essential practice in building websites generally. This process involves checking user input on a website against a set of standard guidelines.

This means that the website will flag and return an error if a user tries to submit faulty data or fake information, preventing malicious or invalid data from accessing the database.

Input validation is essential for several reasons some of which include the following;

  • User experience

  • Smooth data flow

  • Maintaining website security

Web applications can have input validation implemented on both the client and server sides.

In its simplest form, by adding the 'required' prop to the input tag in HTML, you ensure all data must be entered before submission.

In this article, we will discuss how to perform input validation on the client side of web applications built with React and Typescript using Zod.

What is Zod?

Zod is a Typescript-first input validation library. Zod analyzes user inputs and validates them against a predefined object schema, it then throws errors to indicate rules which are not satisfied. Zod can also be used in JavaScript projects. Its functionality is not limited to typescript.

In this article, we will look at a practical example of how to use Zod to validate user inputs in a React app.

Folder structure

Create Next app

In the terminal, run the following command to create a Next js app:

npm create-next-app@latest zod-tutorial –ts

If you’re using yarn, use the command: yarn create-next-app@latest zod-tutorial –ts

After running the code above in the terminal, you will be presented with a few prompts similar to what is shown below;

√ Would you like to use ESLint? ... No / Yes

√ Would you like to use Tailwind CSS? ... No / Yes

√ Would you like to use src/ directory? ... No / Yes

√ Would you like to use App Router? (recommended) ... No / Yes

√ Would you like to customize the default import alias? ... No / Yes

√ What import alias would you like configured? ... @/*

Once the setup is complete, in the terminal enter cd zod-tutorial to enter the root directory of our project.

Installing Dependencies

To get Zod working, we need to install a few dependencies; run the code below in the terminal to install these dependencies.

For npm users run the following script in the terminal

npm i zod react-hook-form @hookform/resolvers

For yarn users

yarn add zod react-hook-form @hookform/resolvers

React-hook-form: is a react library used in handling form management. It is also used in handling resolvers for form validation.

@hookform/resolvers: is an extension of the react-hook-form library which helps to integrate external validation libraries; in our case Zod.

Once the dependencies have been installed successfully, run the npm run dev command in the terminal to start up our server in localhost.

Zod Schema

Below is an example of a basic Zod schema

import { object, string } from 'zod'
const schema = object({
  email: string().email().min(1, { message: 'Required' }),
  password: number().min(10),
});

In the scenario above, a zod object is created with two elements that it expects from the input.

The object then goes further to define a set of rules for the inputs.

The email input defines the following set of rules:

  • It must be of type string

  • It must be a valid email

  • The minimum number of characters required is one (1)

  • If it doesn’t meet any of the above requirements, an error is returned with the text contained in the message.

The password defines that the input must be of a number type and have a minimum of ten (10) characters.

create Zod schema object

Make a new folder called lib and add a new file called "createInput.ts" to the src folder. Within the createInput.ts file, duplicate the following code.

import { object, string, TypeOf } from 'zod';


export const createUserSchema = object({
  email: string()
    .min(1, {message: 'email is required'})
    .email({ message: 'invalid email' }),
  username: string().min(1, {message: 'username is required'}),
  password: string().min(6, {message: 'password must be more than six characters'}),
  passwordConfirmation: string().min(1, {
    message: 'Confirm Password is required',
  }),
}).refine((data) => data.password === data.passwordConfirmation, {
  message: 'Passwords do not match’,
  path: ['passwordConfirmation'],
});


export const createSessionSchema = object({
  email: string()
    .min(1, 'email is required')
    .email({ message: 'invalid email' }),
  password: string().min(6, 'password must be more than six characters'),
});


export type createUserInput = TypeOf<typeof createUserSchema>;
export type createSessionInput = TypeOf<typeof createSessionSchema>;

This userSchema here shows the zod object which defines the set of rules against which the user inputs will be validated.

Using the email key as an example, it defines that the input by the user must be a string and a valid email, and the minimum character entered by the user is 1. If any of these rules are not satisfied, an error will be flagged and a corresponding error message will be returned.

The same rule applies to all other keys within the object.

The .refine() method is used to implement custom validation checks. In this case, it is used to confirm that the values of the password and confirm password match each other perfectly.

Create Reusable Components

We first build two reusable components which are the input and button components respectively

Input component

Create a components folder in the src directory and then copy the contents from the components folder in the GitHub repository and paste them inside your components folder

The code structure should look like the image below:

Button Component

Register and Login UI

Once you've created the reusable components, create the UI layout for the register and the login pages for which we will implement the form validation.

In the app directory, create a new folder and name it “(auth)”. You might be wondering why the auth is in parenthesis, the reason is simple;

Next js 13 uses the new Approuter, to create dynamic routes which appear in the browser like this:

[ http://localhost:3000/login] and [ http://localhost:3000/register]

Instead of having them like this:

[http://localhost:3000/auth/login] and [http://localhost:3000/auth/register].

The parenthesis is used around the parent folder of the route. However, in the event you don’t mind having the routes as usual, you can remove the parenthesis.

You can check my Hashnode to read more on Nextjs 13 best practices.

Register UI

Inside the auth folder, create two folders register, and login.

Create a page.tsx file in the register folder and add the following lines of code:

'use client'


import React from 'react'
import { TextInput } from '@/components/Inputs'
import { SubmitBtn } from '@/components/buttons'


const Register = () => {
  return (
    <div className='flex my-10 justify-center items-center min-h-[100vh]'>
        <form className="group w-full lg:w-2/5 space-y-8 rounded-lg border border-transparent px-5 py-8 transition-colors border-gray-300 bg-gray-100 dark:border-neutral-700 dark:bg-neutral-800/30">
            <h2 className="font-inter text-2xl font-bold leading-3 text-center ">Create your account</h2>
            <div className="space-y-4">
            <TextInput placeholder='Email' type='text' name='email'/>
            <TextInput placeholder='Username' type='text' name='username'/>
            <TextInput placeholder='Password' type='password' name='password'/>
            <TextInput placeholder='Re-enter Password' type='password' name='passwordConfirmation'/>
            </div>
            <SubmitBtn />
        </form>
    </div>
  )
}
export default Register

Login UI

Copy the following lines of code into the login page

'use client'


import { TextInput } from '@/components/Inputs'
import { SubmitBtn } from '@/components/buttons'


const Login = () => {


  return (
    <div className='flex justify-center items-center min-h-[100vh]'>
        <form className="group w-full lg:w-2/5 space-y-8 rounded-lg border border-transparent px-5 py-8 transition-colors border-gray-300 bg-gray-100 dark:border-neutral-700 dark:bg-neutral-800/30">
            <h2 className="font-inter text-2xl font-bold leading-3 text-center ">Sign-in to your account</h2>
            <div className="space-y-4">
            <TextInput placeholder='Email' type='text' name='email'/>
            <TextInput placeholder='Password' type='password' name='password'/>
            </div>
            <SubmitBtn />
        </form>
    </div>
  )
}


export default Login

If we run our server now after successfully pasting the above lines of code, we should see the UI layout for both the register and login pages respectively.

Register layout.

Login Layout.

Implementing react-hook-form

Once we have a successful input layout, it is time to implement react-hook-form. This library collects all the input data and stores them via the register prop. Each input data is stored with reference to the name of the input.

<input type=“text” name=“email” {...register} >

To better understand using the input above, if the data is logged into the console the expected output will be:

email: [user input]

Remember: the register prop is coming from the react-hook-form.

At the top of the register and login page, add the code below to import useForm from the react-hook-form library

import { useForm } from 'react-hook-form

At the top of the function add the following line of code

const { register,handleSubmit, formState: {errors}} = useForm({})

We can then add the register prop to the custom input component

<TextInput … register={register} />

To avoid typescript flagging errors, we add 'register' to the textInputprops and pass it into the input tag.

The final custom input code will look like this:

import React, { FC } from 'react'


interface TextInputProps {
  placeholder: string,
  type: string,
  name: string,
  register:any
}


const TextInput: FC<TextInputProps> = ({placeholder, type, name, register }) => {
  return (
    <input type={type}
    placeholder={placeholder}  
    {...register(name)}
    className='w-full h-full border-b border-gray-300 bg-gradient-to-b from-zinc-200 py-4 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit  lg:rounded-xl lg:border lg:bg-gray-200 lg:dark:bg-zinc-800/30 px-3 outline-none'/>
)
}
export default TextInput;

Adding zod resolver

To utilize the Zod resolver, import zodResolver from @hookform/resolvers.

import {zodResolver} from '@hookform/resolvers/zod

This function will be called at the instance of useForm.

We also import the types and schemas we created in userInput.ts

const { register,handleSubmit, formState: {errors}} = useForm<createUserInput>({
      resolver: zodResolver(createUserSchema)
  })

for the register page.

 const { register,handleSubmit, formState: {errors}} = useForm<createSessionInput>({
    resolver: zodResolver(createSessionSchema)
})

for the login page.

With that, we have simply implemented Zod resolver in react-hook-form to validate user input.

Finally, we create a submit function:

const Submit = async(data: any) => {


  const { email, password } = data


  const userDetails = {email,password}


 console.log(userDetails)


}

After that, we provide this function into the onSubmit prop in the <form/> element and call it from the handleSubmit function that we obtained from useForm.

<form onSubmit={handleSubmit((data)=>Submit(data))} className="...”/>

A conditional <p/> tag is also added to handle any error. When any of the Zod schema's rules are not followed, an error is reported. A message that corresponds to the specific input condition not met is displayed in this error.

{errors.password && <p>{errors.password?.message}</p>}

The final register and login page code will appear as shown below respectively:

'use client'


import React from 'react'
import { TextInput } from '@/components/Inputs'
import { SubmitBtn } from '@/components/buttons'
import { useForm } from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'
import { createUserInput, createUserSchema } from '@/lib/schema/userInput'


const Register = () => {




  const { register,handleSubmit, formState: {errors}} = useForm<createUserInput>({
      resolver: zodResolver(createUserSchema)
  })


  const Submit = async(data: any) => {


    const {username, email, password } = data


    const userDetails = {username,email,password}


   console.log(userDetails)


  }


  return (
    <div className='flex my-10 justify-center items-center min-h-[100vh]'>
        <form onSubmit={handleSubmit((data)=>Submit(data))} className="group w-full lg:w-2/5 space-y-8 rounded-lg border border-transparent px-5 py-8 transition-colors border-gray-300 bg-gray-100 dark:border-neutral-700 dark:bg-neutral-800/30">
            <h2 className="font-inter text-2xl font-bold leading-3 text-center ">Create your account</h2>
            <div className="space-y-4">
            <TextInput placeholder='Email' type='text' name='email' register={register}/>
            {errors.email && <p>{errors.email?.message}</p>}
            <TextInput placeholder='Username' type='text' name='username' register={register}/>
            {errors.username && <p>{errors.username?.message}</p>}
            <TextInput placeholder='Password' type='password' name='password' register={register}/>
            {errors.password && <p>{errors.password?.message}</p>}
            <TextInput placeholder='Re-enter Password' type='password' name='passwordConfirmation' register={register}/>
            {errors.passwordConfirmation && <p>{errors.passwordConfirmation?.message}</p>}
            </div>
            <SubmitBtn />
        </form>
    </div>
  )
}


export default Register
'use client'


import { TextInput } from '@/components/Inputs'
import { SubmitBtn } from '@/components/buttons'
import { useForm } from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'
import { createSessionInput, createSessionSchema } from '@/lib/schema/userInput'


const Login = () => {


  const { register,handleSubmit, formState: {errors}} = useForm<createSessionInput>({
    resolver: zodResolver(createSessionSchema)
})


const Submit = async(data: any) => {


  const { email, password } = data


  const userDetails = {email,password}


 console.log(userDetails)


}


  return (
    <div className='flex justify-center items-center min-h-[100vh]'>
        <form onSubmit={handleSubmit((data)=>Submit(data))} className="group w-full lg:w-2/5 space-y-8 rounded-lg border border-transparent px-5 py-8 transition-colors border-gray-300 bg-gray-100 dark:border-neutral-700 dark:bg-neutral-800/30">
            <h2 className="font-inter text-2xl font-bold leading-3 text-center ">Sign-in to your account</h2>
            <div className="space-y-4">
            <TextInput placeholder='Email' type='text' name='email' register={register}/>
            {errors.email && <p>{errors.email?.message}</p>}
            <TextInput placeholder='Password' type='password' name='password' register={register}/>
            {errors.password && <p>{errors.password?.message}</p>}
            </div>
            <SubmitBtn />
        </form>
    </div>
  )
}


export default Login

Once we run our server again we can begin to test the functionality.

Conclusion

The importance of form validation cannot be overstressed, and in this article, we have discussed how to use Zod and react-hook form to ensure user input satisfies a set of rules before being submitted into the data stream.