React and express.js form validations with Zod

React and express.js form validations with Zod

Published at


Whether you are a front-end developer or a back-end developer, you probably have faced difficulties with validating forms, sometimes you are writing too many if else statements that will make your code bloated and hard to maintain.

The best way to handle form validations throughout your code is definitely not by writing lines and lines of control flow statements, but to use a library that does all of that for us.

There are many libraries out there that you can use, but in this blog post, I will be using Zod along with React.js to do all of the form validations from the front end and show the errors back to the user.

Also, we will be using Zod again to validate the same form from the back which we will be writing using Node.js and Express.js.

What is Zod?

Zod is a TypeScript-first schema declaration and validation library. Zod can be used for any validation, from a simple string type to a very complex nested object.

The best thing about Zod is that it is very user-friendly and easy to use, it helps avoid duplicate declarations.

You declare the validator and Zod will infer the static type in TypeScript.

For example, you have a simple schema written in Zod

const User = z.object({
  name: z.string(),
  email: z.string().email(),
})

You can get the TypeScript type by writing the following code

type UserType = z.infer<typeof User>

It will generate the following TypeScript type

type UserType = {
    name: string;
    email: string;
}

Some other great aspects of Zod:

  • No dependencies
  • Works in Node.js and all modern browsers
  • Tiny: 8kb minified + zipped
  • Immutable: methods (i.e. .optional()) return a new instance
  • Concise, chainable interface
  • Functional approach: parse, don't validate
  • Works with plain JavaScript too! You don't need to use TypeScript.

So let’s get started with some form validations with Zod.

Getting started

For the front end, we will be creating a simple React application and have a form in it, we will be validating the form by using Zod and showing the errors back to the user if there are any.

Setting up a React application

Let’s create a simple React application using Vite.

Vite is a rapid development tool for modern web projects. It focuses on speed and performance by improving the development experience. Vite uses native browser ES imports to enable support for modern browsers without a build process.

Run the following command to create a Vite project

yarn create vite

After you run the command, it will ask you to name the project.

I will name it “react-zod”.

Vite create provide project name

Vite create provide project name

After that select your preferred front-end framework

Vite React

Vite React

After that select your programming language, and I will choose TypeScript.

Zod does not require you to use TypeScript, so if you want to go ahead with JavaScript it is totally fine.

Vite.js React.js TypeScript project

Vite.js React.js TypeScript project

After that, your application will be created and you can cd into it and install the dependencies by running yarn in your terminal.

I will also install TailwindCSS with React, which makes writing CSS code much easier, follow this simple tutorial to install TailwindCSS in your React project.

I will create a sample form that contains some different data types just for the tutorial.

This is our first and only page of the React application, with this form we will test and validate all of our inputs.

import { ChangeEvent, FormEventHandler, useState } from "react"

function App() {
  const [data, setData] = useState<any>({})

  const onSubmit: FormEventHandler<HTMLFormElement> = (e) => {
    e.preventDefault()
  }

  const onChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target

    if (name === "isAdmin") {
      setData((prev: any) => ({ ...prev, [name]: !prev.isAdmin }))
    } else {
      setData((prev: any) => ({ ...prev, [name]: value }))
    }
  }

  return (
    <div className="w-full max-w-5xl p-5 mx-auto">
      <h1 className="text-center text-3xl font-bold">
        React Form validation with ZOD
      </h1>

      <form
        onSubmit={onSubmit}
        className="mt-10 max-w-sm mx-auto flex flex-col gap-2"
      >
        <div>
          <label className="mb-1">Name</label>
          <input
            className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
            placeholder="Name"
            type="text"
            name="name"
            value={data.name}
            onChange={onChange}
          />
        </div>
        <div>
          <label className="mb-1">Email Address</label>
          <input
            className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
            placeholder="Email Address"
            type="text"
            name="email"
            value={data.email}
            onChange={onChange}
          />
        </div>
        <div>
          <label className="mb-1">Password</label>
          <input
            className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
            placeholder="Password"
            type="password"
            name="password"
            value={data.password}
            onChange={onChange}
          />
        </div>
        <div>
          <label className="mb-1">Percentage</label>
          <input
            className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
            placeholder="Percentage"
            type="text"
            name="percentage"
            value={data.percentage}
            onChange={(e) => {
              const value = Number(e.target.value)
              setData((p: any) => ({ ...p, percentage: value }))
            }}
          />
        </div>
        <div>
          <label className="mb-1">Is Admin</label>
          <input
            className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
            placeholder="Boolean"
            type="checkbox"
            name="isAdmin"
            checked={data.isAdmin}
            onChange={onChange}
          />
        </div>

        <button
          type="submit"
          className="py-2 px-5 rounded-lg border hover:bg-gray-50"
        >
          Submit
        </button>
      </form>
    </div>
  )
}

export default App

The code above is a simple React component that includes a simple form inside of it which has a few fields that are name, email, password, percentage, isAdmin each with a different data type.

The goal is to validate any data that will be filled by any user and only submit the form when all of the fields pass all of the form validations that will be taken care of by Zod.

We have a onChange function that will be triggered whenever each input has a change in its value

  const onChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target

    if (name === "isAdmin") {
      setData((prev: any) => ({ ...prev, [name]: !prev.isAdmin }))
    } else {
      setData((prev: any) => ({ ...prev, [name]: value }))
    }
  }

We are also checking if the name is equal to isAdmin in that case we are changing the state to the opposite value of the previous value to get a Boolean value.

The onSumbit button is an empty function which only has a simple function inside of it which is the e.preventDefault() which will prevent the page to reload. We will come back to this function later to handle the submit form action.

This is how the application looks like

React from with zod

Installing Zod

Install Zod by running the following command in the terminal

yarn add zod

Important: If you are using TypeScript, it is required that you have TypeScript version 4.1 or above installed on your machine, if you haven’t make sure you install the latest version by running the following command

yarn global add typescript@latest

Also it is important to set your strict value as true in your tsconfig.json file.

Now it’s time to create a schema using Zod.

Creating a schema with Zod

First, let's create a folder and store all of our schemas inside of it.

Create a folder to store all of your schemas inside of your src directory, I will name the folder schemas

Create a schema type for the form above inside of that folder, src/schemas/form.ts

This is how the folder structure looks like

Vite folder structure

Now we can use zod by importing it.

import { z } from "zod"

Creating our Form object with zod

import { z } from "zod"

export const Form = z.object({
  name: z.string(),
  email: z.string().email(),
  password: z.string(),
  percentage: z.number(),
  isAdmin: z.boolean(),
})

As you can see, we are using the object property of zod to tell it that our Form object has some specific properties with specific types.

name: z.string() makes sure that name is always string

email: z.email() makes sure that the email field has a valid e-mail address.

percentage: z.number() makes sure that the percentage is a valid number.

isAdmin: z.boolean() makes sure that the field contains a boolean.

But this is not all, there are so many other things we can do with zod like making sure a certain property has a specific length, or a number is between a specific range.

We can also add custom error messages when a particular field fails validation, for example, if we want the name to have a minimum length of 2 and a maximum length of 32, we can do something like this

const name = z
  .string()
  .min(2, "Name must be at least 2 characters")
  .max(32, "Name must be at most 32 characters")

Let’s make our form much better by adding more validations to it.

import { z } from "zod"

export const Form = z.object({
  name: z
    .string({ description: "Your name" })
    .min(2, "Name must be at least 2 characters")
    .max(32, "Name must be at most 32 characters")
    .optional(),
  email: z.string().email(),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .max(64, "Password must be at most 64 characters"),
  percentage: z
    .number()
    .min(0, "Percentage must be at least 0")
    .max(100, "Percentage must be at most 100"),
  isAdmin: z.boolean().default(false),
})

By default, all of the values are expected to be required, meaning they should be defined and have a value of their specific type when any data is parsed to the schema. We are using the optional() method on the name property to make it an optional property.

Creating TypeScript types from a Zod schema

We can create a TypeScript type directly from our Zod schema just by using the infer method.

export type FormType = z.infer<typeof Form>

This will create the following TypeScript type

type FormType = {
    name?: string | undefined;
    email: string;
    password: string;
    percentage: number;
    isAdmin: boolean;
}

As you can see, the name property is optional in the TypeScript type as well.

Validate the React form with Zod

Now that our schema is ready to be used, let’s write a function to validate the React form by using Zod.

Go to your App.tsx file and import the Form schema that we just created.

import { Form } from "./schemas/form"

Create a function inside of the App component with the name validate to handle the form validation, here is how the validate function looks like

const validate = () => {
  const formData = Form.safeParse(data)

  if (!formData.success) {
    const { issues } = formData.error

    setErrors(issues)
  } else {
    setErrors([])
  }
}

In this function, we are using the safeParse method on the Form object. which will return a property with the name success which is a boolean if the value is true it means that the validation was not successful otherwise it is successful.

After the function creation, we can easily call it whenever the form is submitted

 const onSubmit: FormEventHandler<HTMLFormElement> = (e) => {
    e.preventDefault()

    validate()
  }

Error handling with Zod

When the validation is not successful or the success value equals false then another property will be returned from the safeParse method which is the error object which also has another property on it with the name issues which is exactly what we need.

The issues property is an array of objects with the following schema

{
  code: "invalid_type",
  expected: "string",
  message: "Required",
  path: ["email"],
  received: "undefined",
}

What we need in this object is the message property, we can easily access this message property and use it to show it back to the user so that they will understand why the validation failed.

This message property can also be easily configured when the Zod schema is initialized, for example,

const User = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'), // will show this error if "name" property has a length of less than 2 characters
  email: z.string().email(),
})

Back to our validate function, we are also running a function with the name setErrors which updates the state errors which were defined above by using a simple useState hook.

const [errors, setErrors] = useState([])

If you want to make this type-safe, you can use the ZodIssue type which comes with Zod which can be imported easily

import { ZodIssue } from "zod"

Updating the state

const [errors, setErrors] = useState<ZodIssue[]>([])

Showing the error back to the user

Now that we have written our validate function, if there is any error when the user submits the form, it will update the errors state with the new errors.

We can use this state to show it back to the user.

I will create a new function with the name getError which will get one argument path and return the error message in the errors state.

const getError = (path: string) => {
    const error = errors.find((error) => error.path === path)

    return error ? (
      <small className="text-red-500">{error?.message}</small>
    ) : null
  }

As you can see we are returning a JSX element <small> with a red colour.

We can run this function under every input element in our Form component.

This is how our Form component looks like

<form
  onSubmit={onSubmit}
  className="mt-10 max-w-sm mx-auto flex flex-col gap-2"
>
  <div>
    <label className="mb-1">Name</label>
    <input
      className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
      placeholder="Name"
      type="text"
      name="name"
      value={data.name}
      onChange={onChange}
    />

    {getError("name")}
  </div>
  <div>
    <label className="mb-1">Email Address</label>
    <input
      className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
      placeholder="Email Address"
      type="text"
      name="email"
      value={data.email}
      onChange={onChange}
    />
    {getError("email")}
  </div>
  <div>
    <label className="mb-1">Password</label>
    <input
      className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
      placeholder="Password"
      type="password"
      name="password"
      value={data.password}
      onChange={onChange}
    />
    {getError("password")}
  </div>
  <div>
    <label className="mb-1">Percentage</label>
    <input
      className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
      placeholder="Percentage"
      type="text"
      name="percentage"
      value={data.percentage}
      onChange={(e) => {
        const value = Number(e.target.value)
        setData((p: any) => ({ ...p, percentage: value }))
      }}
    />
    {getError("percentage")}
  </div>
  <div>
    <label className="mb-1">Is Admin</label>
    <input
      className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
      placeholder="Boolean"
      type="checkbox"
      name="isAdmin"
      checked={data.isAdmin}
      onChange={onChange}
    />
    {getError("isAdmin")}
  </div>

  <button
    type="submit"
    className="py-2 px-5 rounded-lg border hover:bg-gray-50"
  >
    Submit
  </button>
</form>

Trying it out

Now it is time to try out our application and see if it works as expected.

First, let's submit the form with empty values.

zod validating empty field

As you can see we get required errors for objects that didn’t have the optional() method run on.

Let’s add data to the fields

Zod validating react form

Now we no longer get the required error messages, but we get other validation messages that we have defined above.

If we fill out the form without any mistakes, we no longer will see the validation errors.

Zod successful validation with React

Congratulations, now your form is validated with as little effort as possible, thanks to Zod there is no longer the need to write lines and lines of if and else cases and other control flow statements to validate your forms.

Validating a form in Node.js with Zod

let’s create a simple Node.js backend by using express.js to validate the same form that we have just created, but this time from the backend and sending back the proper error messages to the client.

This is how our simple node.js server code looks like

const express = require('express')
const app = express()

app.use(express.json())

app.post('/form/submit', (req, res) => {})

const PORT = process.env.PORT || 3000

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`)
})

Let’s update the POST route with the proper code to validate the same data in the form that we created in the front end.

Creating a Zod Schema

First things first, let’s create the Zod schema for the form validations.

const Form = z.object({
  name: z
    .string()
    .min(2, 'Name must be at least 2 characters')
    .max(32, 'Name must be at most 32 characters')
    .optional(),
  email: z.string({ required_error: 'Email is required' }).email(),
  password: z
    .string({ required_error: 'Password is required' })
    .min(8, 'Password must be at least 8 characters')
    .max(64, 'Password must be at most 64 characters'),
  me: z.string({ required_error: 'You must provide me' }),
  percentage: z
    .number({ required_error: 'Percentage is required' })
    .min(0, 'Percentage must be at least 0')
    .max(100, 'Percentage must be at most 100'),
  isAdmin: z.boolean().default(false)
})

This one is a bit different, when we are initializing a type of a property like z.string() we are also passing in the options and setting the required_error property, this way were are able to overwrite the default required error message.

Creating and validating the route with express and Zod

Now that our schema is ready to use, let’s create a POST route with express and validate the input data with Zod.

app.post('/form/submit', (req, res) => {
  try {
    const body = Form.safeParse(req.body)

    if (!body.success) {
      res.status(400).json({ message: body.error.issues[0].message })
      return
    }

    // Do something with the data

    res.json({
      message: 'Form submitted successfully'
    })
  } catch (error) {
    res.json({
      message: error.message
    })
  }
})

Of course, you can send a different response back to the client if there is an issue in validating the form, but for this tutorial, I will be sending back the first issue message.

Let’s test our route with various data and see if it works as expected

First test

// request
{
  "name": "John",
  "email": "test", 
  "password": "123456",
  "isAdmin": "true"
}

// response
{
  "message": "Invalid email"
}

Second test

// request
{
  "name": "John",
  "email": "test@gmail.com", 
  "password": "123456",
  "isAdmin": "true"
}

// response
{
  "message": "Password must be at least 8 characters"
}

Third test

// request

{
  "name": "John",
  "email": "test@gmail.com", 
  "password": "12345645",
  "isAdmin": "true"
}

// response
{
  "message": "Percentage is required"
}

Fourth test

// request
{
  "name": "John",
  "email": "test@gmail.com", 
  "password": "12345645",
  "isAdmin": "true", 
  "percentage": 3
}

// response
{
  "message": "Expected boolean, received string"
}

Final test

// request
{
  "name": "John",
  "email": "test@gmail.com", 
  "password": "12345645",
  "isAdmin": true, 
  "percentage": 3
}

// response
{
  "message": "Form submitted successfully"
}

Congratulations, now your form is validated strongly from the back end as well, without having to do too many configurations and writing endless lines of code which will make your code extremely bloated and making it more difficult to maintain.

Since Zod works perfectly well with TypeScript, it will make your code type-safe leaving no space for unexpected errors and bugs.

Thanks for reading this blog post, if you enjoyed the blog, make sure you share it with other developers so that they can benefit from it as well.

If you have any notes, please let me know from the comments.

Comments

We won't show your email address!

500 characters left
Saifullah Rahman avatar

Saifullah Rahman

March 29, 2023

Thanks a lot!
That was really helpful