DEV Community

Cover image for How to build a professional sign up page using tanstack form and react?
PARTHIV SAIKIA
PARTHIV SAIKIA

Posted on

How to build a professional sign up page using tanstack form and react?

Introduction

The web is all about forms. From the signup form to the search field in your favorite website all are just forms. Web forms are built by using the html5 form tag. In react the JSX component form is used to build forms.

Problems in the form component

If you are building a simple login form with just two fields i.e username and password then the form component is sufficient. But there are many cases where you need to build a much complex form e.g building a form for creating a tour with images, dates, multiple places for a tour booking website. In such cases using the form component solely can become a headache. You may ask why? The reason is that in such forms you need to keep track of a lot of states like 'Is this dialog opened?', 'Is the value of Password and Confirm Password field same?'. It is also much harder to validate form values with the form component only and requires a lot of boilerplate.

Tanstack form for the rescue

Tanstack form is a modern headless, type safe and performant form library. It can be used with most modern frameworks such as react, svelte, angular, etc. It solves all the problems we discussed very efficiently. It gives builtin type safe state management APIs for interacting with the form fields.

What we will build

In this blog we will build a simple but professional signup form for our dream SaaS using react and tanstack form. The core features of the form will be:

  • Input validation with clear, real-time error messages for each field.

  • Secure password confirmation to ensure the user types their password correctly.

  • Submission state management to show a loading indicator and prevent multiple submissions.

  • End-to-end type safety to catch bugs between your code and your UI.

Prerequisites

Before you begin you are expected to:

  • Have node.js installed.
  • Have basic knowledge of typescript and react.

And that's it so let's begin the fun!!

Tech stack and project structure

We will use React for the frontend. For the backend we won't use any real API server but we will create a fake rest API using JSON-server.

For forms we will use the Tanstack form.

Scaffolding the project

Creating the project folder

Let's create the project folder using Vite. Navigate to the folder of your choice (e.g. code or projects) and run this command from the terminal npm create vite@latest signup-page -- --template react-ts. This will create a react project named signup-page and the language will be typescript since we have used the template react-ts.

I will refer to the signup-page folder as the root folder in the whole blog. Now navigate to the signup-page folder using the command
cd signup-page and run npm install to install all the dependencies. Now run npm run dev to initiate the development server. Now visit localhost:5173 and you will see this webpage.

An image showing the initial page in localhost:5173

Now let's start building our signup form. First of all install tanstack form using the command npm i @tanstack/react-form. Now remove the content of the file signup-page/src/App.tsx so that we can create our own components. You can also delete the signup-page/src/assets/ folder since we won't need the react.svg file present inside it.

Creating the form

Let's start by editing the signup-page/src/App.tsx file. Import tanstack form with this line.

import { useForm } from "@tanstack/react-form"
Enter fullscreen mode Exit fullscreen mode

The useForm hook is used for creating a form instance.

Now create the main component in App.tsx like this

export default function SignupPage() {
  return <div>Signup Page</div>;
}
Enter fullscreen mode Exit fullscreen mode

Creating the fake rest api server using JSON-server

Now let's pause the frontend development and wear the backend developer cap. Although we are not going to use any framework or any real database still we need to add some code to build fake Rest API end points.

Create a file db.json in the root folder. Just like a database we will define our entities in this file. Since we are creating a signup form our entity is users. Let's add a test user. Paste this code in db.json.

{
  "users": [
    {
      "name": "John Doe",
      "emailId": "johndoe@gmail.com",
      "password": "yourpassword",
      "dateOfBirth": "2000-01-01T00:00:00Z"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This is for demonstration only and we will add users with the endpoints only.

To start the fake server run npx json-server --port 3000 db.json from the root directory. Now go to localhost:3000/users. You will see the users we defined in the db.json file like this:

Image showing the fake server running

You can also check this using Postman. I am going to use curl to fetch the users. Run this command from the terminal curl http://localhost:3000/users. You will get the output like this.

[
  {
    "name": "John Doe",
    "emailId": "johndoe@gmail.com",
    "password": "yourpassword",
    "dateOfBirth": "2000-01-01T00:00:00Z",
    "id": "f267"
  }
]
Enter fullscreen mode Exit fullscreen mode

Congratulations!! You have successfully started the fake json-server and a GET endpoint too. Json-server makes our life easier because we don't need to create endpoints by ourselves.

Before moving on let's add a script to start the fake json-server so that we don't need to type the long command repetitively. Open your package.json file and add this line in the scripts section

"server": "npx json-server --port 3000 db.json"
Enter fullscreen mode Exit fullscreen mode

Now go to the terminal where your json-server is running and press Ctrl + C to close it. Now run npm run server. Congratulations ! You have started the server again with a script.

Trying the POST /users endpoint

Now to create a new user we need to send a POST request with a body to the endpoint /users. The request body should contain fields like:
"name", "emailId", "password", "dateOfBirth". Since this a frontend focused blog and our main goal is to learn how to use tanstack form we won't dive into fundamentals such as "how to handle passwords securely?", "what are the good rest practices?". Run this command from the terminal

curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"name": "James Maxwell", "emailId": "james985@gmail.com",
"password": "mypassword", "dateOfBirth": "2000-02-01T00:00:00Z"}' \
  http://localhost:3000/users
Enter fullscreen mode Exit fullscreen mode

You will get the response as:

{
  "id": "b01f",
  "name": "James Maxwell",
  "emailId": "james985@gmail.com",
  "password": "mypassword",
  "dateOfBirth": "2000-02-01T00:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

Now run curl http://localhost:3000/users and you will see the following response:

[
  {
    "name": "John Doe",
    "emailId": "johndoe@gmail.com",
    "password": "yourpassword",
    "dateOfBirth": "2000-01-01T00:00:00Z",
    "id": "f267"
  },
  {
    "id": "b01f",
    "name": "James Maxwell",
    "emailId": "james985@gmail.com",
    "password": "mypassword",
    "dateOfBirth": "2000-02-01T00:00:00Z"
  }
]
Enter fullscreen mode Exit fullscreen mode

This response shows that we have successfully created a new user. Now go to the file db.json. You can now see that there is a new user in the users array.

{
  "users": [
    {
      "name": "John Doe",
      "emailId": "johndoe@gmail.com",
      "password": "yourpassword",
      "dateOfBirth": "2000-01-01T00:00:00Z",
      "id": "f267"
    },
    {
      "id": "b01f",
      "name": "James Maxwell",
      "emailId": "james985@gmail.com",
      "password": "mypassword",
      "dateOfBirth": "2000-02-01T00:00:00Z"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Now since we have our endpoints ready let's develop the form.

Building the form with react and tanstack form

Go to the file App.tsx. Create a new form instance using the useForm hook like this

export default function SignupPage() {
  const form = useForm({
    defaultValues: {
      name: "",
      emailId: "",
      password: "",
      confirmPassword: "",
      dateOfBirth: "",
    },
    onSubmit: async ({ value }) => {
      console.log(value);
    },

  });
  return 
<div>
    <h1>Signup Page</h1>
    <form 
        onSubmit={(e) => {
            e.preventDefault();
            e.stopPropagation();
            form.handleSubmit();
         }}
    ></form>
</div>;
}
Enter fullscreen mode Exit fullscreen mode

In the defaultValues property of the useForm hook we have defined all the fields of our signup form and initiated them as empty strings. These values will be used as the value of the name attribute of our input fields. e.g. <input name="emailId"/>.

The onSubmit property declares what will happen when the form gets submitted.

We also have added a form tag with its own onSubmit handler which just calls the form.handleSubmit function.

Let's add our first field i.e. the name field. Paste this component inside the form component.

<div>
    <label htmlFor="name">Name</label>
    <form.Field
        name="name"
        children={(field) => (
            <input
                id="name"
                name={field.name}
                value={field.state.value}
                onBlur={field.handleBlur}
                onChange={(e) => field.handleChange(e.target.value)}
                type="text"
                />
              )}
            />
 </div>
Enter fullscreen mode Exit fullscreen mode

Let's go through the component line by line. The label tag is a standard html tag which is used semantically for displaying the information about an input field. After that we use the form instance that we created. The form instance provide a form.Field component which is used to create a Field for the form. The form.Field component has two mandatory properties: "name" and "children". The name property of the fields must match with the properties of the defaultValues property of the form instance. In this case we have kept the name as name. Now comes the most important property of the form.Field component the children property. This property will render the component that will be define inside it. Usually we render an input component inside it. The field argument in the function represents the field whose property the children is. We can access value of the field using field.state.value, the name as field.name and even errors and field.state.meta.errors. We have rendered an input component inside the children property. The value attribute of the input component is given the value of the field using field.state.value similarly the name attribute is given the value of field.name. The onChange property is required and without it the input component will not work. This is because without it the form.Field component doesn't know what it needs to do when the value of the input component changes.

Now let's add the other fields i.e. for email, password, confirm password and date of birth. Now your full App.tsx should look like this:

import { useForm } from "@tanstack/react-form";

export default function SignupPage() {
  const form = useForm({
    defaultValues: {
      name: "",
      emailId: "",
      password: "",
      confirmPassword: "",
      dateOfBirth: "",
    },
    onSubmit: async ({ value }) => {
      console.log(value);
    },
  });

  return (
    <div>
      <div>
        <h2>Create an Account</h2>
        <p>Join us and start your journey!</p>

        <form
          onSubmit={(e) => {
            e.preventDefault();
            e.stopPropagation();
            form.handleSubmit();
          }}
        >
          {/* Name Field */}
          <div>
            <label htmlFor="name">Name</label>
            <form.Field
              name="name"
              children={(field) => (
                <input
                  id="name"
                  name={field.name}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(e) => field.handleChange(e.target.value)}
                  type="text"
                />
              )}
            />
          </div>

          {/* Email Field */}
          <div>
            <label htmlFor="emailId">Email</label>
            <form.Field
              name="emailId"
              children={(field) => (
                <input
                  id="emailId"
                  name={field.name}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(e) => field.handleChange(e.target.value)}
                  type="email"
                />
              )}
            />
          </div>

          {/* Password Field */}
          <div>
            <label htmlFor="password">Password</label>
            <form.Field
              name="password"
              children={(field) => (
                <input
                  id="password"
                  name={field.name}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(e) => field.handleChange(e.target.value)}
                  type="password"
                />
              )}
            />
          </div>

          {/* Confirm Password Field */}
          <div>
            <label htmlFor="confirmPassword">Confirm Password</label>
            <form.Field
              name="confirmPassword"
              children={(field) => (
                <input
                  id="confirmPassword"
                  name={field.name}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(e) => field.handleChange(e.target.value)}
                  type="password"
                />
              )}
            />
          </div>

          {/* Date of Birth Field */}
          <div>
            <label htmlFor="dateOfBirth">Date of Birth</label>
            <form.Field
              name="dateOfBirth"
              children={(field) => (
                <input
                  id="dateOfBirth"
                  name={field.name}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(e) => field.handleChange(e.target.value)}
                  type="date"
                />
              )}
            />
          </div>

          {/* Submit Button */}
          <div>
            <button type="submit">Sign up</button>
          </div>
        </form>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

All the other fields just follow the same pattern as the name field and works the same way. We also have added a button to submit the form. Now visit localhost:5173 and you will see something like this

Image showing unstyled form

The form is not looking that good. So let's add our own styles and make it beautiful. We will use tailwindcss for this.

Installing tailwindcss with vite

Run npm install tailwindcss @tailwindcss/vite from the root directory to install tailwindcss. Now open your vite.config.ts file and edit it to make it look like this

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
});
Enter fullscreen mode Exit fullscreen mode

Now open index.css and remove all of its content and add this line to the top of it.

@import "tailwindcss";
Enter fullscreen mode Exit fullscreen mode

Now you are ready to add styles using tailwindcss. You are free to style the form on your own. I am styling it like this

import { useForm } from "@tanstack/react-form";

export default function SignupPage() {
  const form = useForm({
    defaultValues: {
      name: "",
      emailId: "",
      password: "",
      confirmPassword: "",
      dateOfBirth: "",
    },
    onSubmit: async ({ value }) => {
      console.log(value);
    },
  });

  return (
    <div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-4xl w-full space-y-8">
        <div className="text-center">
          <h2 className="text-3xl font-bold text-gray-900">
            Create an Account
          </h2>
          <p className="mt-2 text-sm text-gray-600">
            Join us and start your journey!
          </p>
        </div>

        <form
          onSubmit={(e) => {
            e.preventDefault();
            e.stopPropagation();
            form.handleSubmit();
          }}
          className="bg-white shadow-lg rounded-lg p-8 space-y-6"
        >
          <div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
            {/* Name Field */}
            <div className="space-y-1">
              <label
                htmlFor="name"
                className="block text-sm font-medium text-gray-700"
              >
                Name
              </label>
              <form.Field
                name="name"
                children={(field) => (
                  <input
                    id="name"
                    name={field.name}
                    value={field.state.value}
                    onBlur={field.handleBlur}
                    onChange={(e) => field.handleChange(e.target.value)}
                    type="text"
                    className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
                  />
                )}
              />
              <div className="h-5"></div>
            </div>

            <div className="space-y-1">
              <label
                htmlFor="emailId"
                className="block text-sm font-medium text-gray-700"
              >
                Email
              </label>
              <form.Field
                name="emailId"
                children={(field) => (
                  <input
                    id="emailId"
                    name={field.name}
                    value={field.state.value}
                    onBlur={field.handleBlur}
                    onChange={(e) => field.handleChange(e.target.value)}
                    type="email"
                    className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
                  />
                )}
              />
              <div className="h-5"></div>
            </div>

            <div className="space-y-1">
              <label
                htmlFor="password"
                className="block text-sm font-medium text-gray-700"
              >
                Password
              </label>
              <form.Field
                name="password"
                children={(field) => (
                  <input
                    id="password"
                    name={field.name}
                    value={field.state.value}
                    onBlur={field.handleBlur}
                    onChange={(e) => field.handleChange(e.target.value)}
                    type="password"
                    className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
                  />
                )}
              />
              <div className="h-5"></div>
            </div>

            <div className="space-y-1">
              <label
                htmlFor="confirmPassword"
                className="block text-sm font-medium text-gray-700"
              >
                Confirm Password
              </label>
              <form.Field
                name="confirmPassword"
                children={(field) => (
                  <input
                    id="confirmPassword"
                    name={field.name}
                    value={field.state.value}
                    onBlur={field.handleBlur}
                    onChange={(e) => field.handleChange(e.target.value)}
                    type="password"
                    className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
                  />
                )}
              />
              <div className="h-5"></div>
            </div>

            <div className="md:col-span-2 space-y-1">
              <label
                htmlFor="dateOfBirth"
                className="block text-sm font-medium text-gray-700"
              >
                Date of Birth
              </label>
              <form.Field
                name="dateOfBirth"
                children={(field) => (
                  <input
                    id="dateOfBirth"
                    name={field.name}
                    value={field.state.value}
                    onBlur={field.handleBlur}
                    onChange={(e) => field.handleChange(e.target.value)}
                    type="date"
                    className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
                  />
                )}
              />
              <div className="h-5"></div>
            </div>
          </div>

          <div className="pt-4">
            <button
              type="submit"
              className="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-150 ease-in-out"
            >
              Sign up
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now our form looks like this

Image showing styled form

Now since our form is looking good let's focus on enhancing the UX by validating the data so that the user don't submit wrong data.

Validation is done in tanstack form either in form level or field level. You can chose when to do the validation e.g. onSubmit, onBlur, onChange. Validation can be done by using the callback functions: onChange, onSubmit, onBlur either at the field level or at the form level. The best part about validation in tanstack form is that it allows asynchronous validation too. Let's write our first validator in tanstack form.

Validation in tanstack form

Let's start by validating the name field. We would want that the name of the user should be longer than 3 characters, it should not contain any number and it should not contain any special characters. We will use onChange callback for validating the rules:

  • name should not contain any number.
  • name should not contain any special characters.

Let's implement the first rule. We can implement both the rules using regular expressions. You can learn about regular expressions here.

Create a new directory helpers/ in signup-page/src. This directory will contain the helper functions. Create a new file hasNumber.ts with the following content.

export default function hasNumber(value: string) {
  return /\d/.test(value);
}

// hasNumber('abc') => false
// hasNumber('xyz12') => true
Enter fullscreen mode Exit fullscreen mode

This function checks if the argument value contains any digit. Now import it in App.tsx like this

import hasNumber from "./helpers/hasNumber";
Enter fullscreen mode Exit fullscreen mode

Now we will add a field level validation on the field name. Replace the form.Field component of the name field with this

<form.Field
    name="name"
    validators={{
        onChange: ({ value }) => {
            if (hasNumber(value)) {
                return "Name cannot contain digit.";
             }
             return undefined;
        },
    }}
    children={(field) => (
        <>
           <input
            id="name"
            name={field.name}
            value={field.state.value}
            onChange={(e) => field.handleChange(e.target.value)}
            type="text"
            className="w-full px-3 py-2 border border-gray-300             rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
                    />
          {!field.state.meta.isValid && (
              <em className="text-red-400" role="alert">
                  {field.state.meta.errors.join(", ")}
              </em>
          )}
       </>
     )}
 />
Enter fullscreen mode Exit fullscreen mode

The changes are we have added a callback onChange to the validators property of the form.Field component. The onChange callback takes the current value of the field and returns an string if it has a digit. The string is added to the errorsMap of the field. After that to view the error message we have added a new element just below the input element. The <em> element is conditionally rendered if there is any error in the field. We can keep track of the error state by checking the property field.state.meta.isValid. The errors are present as an array and can be accessed by the property field.state.meta.errors. We are just joining them using the Array.prototype.join method.

Now if you type a digit in the name field you will see an error just below it like this

Image showing onChange error

Now let's add the other rule i.e. name should not contain any special characters.

Create another file hasSpecialChar.ts in helpers/ directory with the following content.

export default function hasSpecialCharacters(value: string) {
  // This regex looks for any character that is NOT a-z, A-Z, or 0-9
  const specialChars = /[^a-zA-Z0-9\s]/;
  return specialChars.test(value);
}
Enter fullscreen mode Exit fullscreen mode

This function returns true if the string contains any characters other than [0-9], [a-z], [A-Z].

Now to use it on the name field add an or condition to the onChange callback.

onChange: ({ value }) => {
    if (hasNumber(value) || hasSpecialCharacters(value)) {
        return "Name cannot contain digit or special characters.";
    }
    return undefined;
}
Enter fullscreen mode Exit fullscreen mode

Now if you type any digit or special character you will get the error message which says that Name cannot contain digit or special characters.

Now we will add an onBlur callback. It will check if the name is longer than 3 characters or not.

In the validators property add the onBlur callback below the onChange validator.

onBlur: ({ value }) => {
    if (value.length < 3) {
        return "Name must be longer than 3 characters.";
    }
    return undefined;
}
Enter fullscreen mode Exit fullscreen mode

Also in the input component add the onBlur attribute as field.handleBlur.

The whole form.Field component should look like this

<form.Field
  name="name"
  validators={{
    onChange: ({ value }) => {
      if (hasNumber(value) || hasSpecialCharacters(value)) {
        return "Name cannot contain digit or special characters.";
      }
      return undefined;
    },
    onBlur: ({ value }) => {
      if (value.length < 3) {
        return "Name must be longer than 3 characters.";
      }
      return undefined;
    },
  }}
  children={(field) => (
    <>
      <input
        id="name"
        required
        name={field.name}
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
        type="text"
        className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
      />
      {!field.state.meta.isValid && (
        <em className="text-red-400" role="alert">
          {field.state.meta.errors.join(", ")}
        </em>
      )}
    </>
  )}
/>
Enter fullscreen mode Exit fullscreen mode

The syntax of the onBlur callback is similar to the onChange callback. We also have added required attribute to the input element so that we get html5 validation and the form can't be submitted without this field.

Now if you enter a name with less than 3 characters and focus on any other field you will get an error like this

Image showing onBlur error

Now let's focus on the email field. We want the following validations rule on the email field:

  • All emails should be unique i.e. if an user has already registered with an email, the same email can't be used again.

  • The email should be in proper format.

  • Email field shouldn't be empty. (We don't need tanstack form validation for this since html5 validation is sufficient.)

Asynchronous validation in tanstack form

To implement the first validation we need to check in our database if the email that is being typed by the user already exists. Since this is a operation to be done over network requests we need to use asynchronous validation.

For asynchronous validation we will use the onChangeAsync callback.

To make network request to the json-server we will use axios. So install it using the command
npm install axios.

Create a new file existingEmail.ts in the helpers/ directory with the following content

import axios from "axios";
export default async function existingEmail(value: string) {
  const response = await axios.get(
    `http://localhost:3000/users?emailId=${value}`,
  );
  return response.data.length !== 0;
}
Enter fullscreen mode Exit fullscreen mode

Json-server provide endpoints based on queries on its own and we don't need to create a separate endpoint for fetching users with the given email. Before using this code let's test the endpoint from the terminal to get a better understanding of it.

Run this code from the terminal

 curl http://localhost:3000/users?emailId=james985@gmail.com
Enter fullscreen mode Exit fullscreen mode

You will get the following result

[
  {
    "id": "b01f",
    "name": "James Maxwell",
    "emailId": "james985@gmail.com",
    "password": "mypassword",
    "dateOfBirth": "2000-02-01T00:00:00Z"
  }
]
Enter fullscreen mode Exit fullscreen mode

Since we have an user with the email james985@gmail.com we got this result. Now if we try with a non existing email we will get nothing.
Run this code curl http://localhost:3000/users emailId=james100@gmail.com. The result is just an empty array.

Now let's integrate the existingEmail function with tanstack form. Add a new validators property in the form.Field component of the email field. Now add onChangeAsync callback to this validators property like this

<form.Field
    name="emailId"
    validators={{
        onChangeAsync: async ({ value }) => {
            if (await existingEmail(value)) {
                return "Email already in use";
            }
            return undefined;
        },
        onChangeAsyncDebounceMs: 1000,
    }}
    children={(field) => (
        <>
            <input
                id="emailId"
                name={field.name}
                required
                value={field.state.value}
                onChange={(e) => field.handleChange(e.target.value)}
                type="email"
                className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
           />
           {!field.state.meta.isValid && (
               <em className="text-red-400" role="alert">
                   {field.state.meta.errors.join(", ")}
               </em>
           )}
        </>
     )}
/>

Enter fullscreen mode Exit fullscreen mode

We have use the existingEmail function wrapped in an asynchronous function. We have used standard async/await syntax. We also have used the onChangeAsyncDebounceMs callback to get builtin debouncing so that we can rate the limit of hitting the endpoint. Now if you type james985@gmail.com in the email field you will get an error.

Image showing error in email field

Now let's implement the next rule i.e. checking if the email is in correct format. For this we will again use a regular expression. Create a new file isValidEmail.ts in helpers/ directory with the following content.

export default function isValidEmail(value: string) {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(value);
}
Enter fullscreen mode Exit fullscreen mode

This regular expression checks if the string is of the email format or not. It checks for rules such that:

  • whether '@' is present or not.
  • if '@' appears for more than one time.
  • whether it has a top level domain or not.

Now use this function in the onChange callback in the email field like this

onChange: ({ value }) => {
    if (!isValidEmail(value)) {
        return "Please provide a valid email.";
    }
    return undefined;
}
Enter fullscreen mode Exit fullscreen mode

Now if we don't provide '@' in our email it will throw us an error.

Image showing error with wrong format error

We also have added required attribute in the input element so that it doesn't allow the form to submit if we don't provide any data. If you want you can add an onBlur callback to implement the error that field cannot be empty.

Now one of the most important aspect of web security is a strong password and we as a good developer should never allow our users to submit a weak password. A strong password on the least should follow these criteria:

  • A minimum of 8 characters.
  • It must contain an uppercase letter.
  • It must contain an lowercase letter.
  • It must contain a number.
  • It must contain a special character.

Let's implement all of these one by one. Again our rescue is regular expression.

Create a new file password.ts in the helper/ directory. Paste the following content in it.

export function checkPassword(value: string): string | null {
  const errors = [];

  if (value.length < 8) {
    errors.push("Password must be at least 8 characters long.");
  }

  if (!/[A-Z]/.test(value)) {
    errors.push("Password must contain at least one uppercase letter.");
  }

  if (!/[a-z]/.test(value)) {
    errors.push("Password must contain at least one lowercase letter.");
  }

  if (!/[0-9]/.test(value)) {
    errors.push("Password must contain at least one number.");
  }

  if (!/[!@#$%^&*(),.?":{}|<>]/.test(value)) {
    errors.push("Password must contain at least one special character.");
  }

  if (errors.length > 0) {
    return errors.join(" ");
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

This function defines multiple regular expressions and it test them against the value string. The meaning of the regular expressions can be understood by the error message they are returning. We are pushing the errors in to a errors array and then at the end we are returning a string by joining the join method. If there is no error we return null.

This function is little bit different as you can see since it doesn't return a boolean like our earlier helper functions. So it will be used differently than others. The onChange callback will still be the same but we will directly return the value of checkPassword function instead of using an if condition. The form.Field component should now look like this.

<form.Field
    name="password"
    validators={{
        onChange: ({ value }) => {
            return checkPassword(value)?.split(".");
        },
    }}
    children={(field) => (
    <>
        <input
            id="password"
            name={field.name}
            value={field.state.value}
            onBlur={field.handleBlur}
            onChange={(e) => field.handleChange(e.target.value)}
            type="password"
            className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
         />
         {!field.state.meta.isValid && (
             <ul className="text-red-500" role="alert">
                 {field.state.meta.errors.map((error) => (
                     <li>
                         <div>{error}</div>
                      </li>
                  ))}
             </ul>
          )}
      </>
    )}
 />

Enter fullscreen mode Exit fullscreen mode

Since checkPassword function is returning a string we have extracted into multiple strings by using the split function. Now we have an array of errors which we are showing as a list by mapping over them. Now the errors are shown below the password field in a list.

Image showing errors of password

For a better experience we can show the users whether the value of password field and the confirm password are the same. We can achieve this by making the confirm password field listen to the password field. We can achieve this using the onChangeListenTo property in the validators property of the form.Field component of the confirm password field. The new form.Field component looks like this

<form.Field
    name="confirmPassword"
    validators={{
        onChangeListenTo: ["password"],
        onChange: ({ value, fieldApi }) => {
            if (value !== fieldApi.form.getFieldValue("password")) {
                return "Passwords don't match";
            }
            return undefined;
         },
     }}
     children={(field) => (
         <>
             <input
                 id="confirmPassword"
                 required
                 name={field.name}
                 value={field.state.value}
                 onChange={(e) =>field.handleChange(e.target.value)}
                 type="password"
                 className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
            />
             {!field.state.meta.isValid && (
                 <em className="text-red-500" role="alert">
                     {field.state.meta.errors.join(", ")}
                 </em>
              )}
         </>
     )}
              />

Enter fullscreen mode Exit fullscreen mode

The validators property in this field is little bit different since we are using a new callback and even the onChange callback is used differently. The line onChangeListenTo: ["password"] states that the confirm password field is listening to any changes in the password field. After that let's look at the line

onChange: ({ value, fieldApi }) => {
    if (value !== fieldApi.form.getFieldValue("password")) {
        return "Passwords don't match";
    }
    return undefined;
}
Enter fullscreen mode Exit fullscreen mode

This is mostly same as our older onChange callbacks. The only change is that we are accessing the field and the form using the fieldApi. The fieldApi.form.getFieldValue("password") method will give us the value of the password field. Now if the values of the password field and the confirm password do not match error will be shown.

Image showing error in confirm password

Now our final field is remaining which is our date of birth field. The range of the dates that will be shown in the calendar can be controlled by the attributes such as min and max in the input component. Since the date of birth must be before the current date so we can set the max as the date of today.

We can create the string date for the current date by the following code.

  const today = new Date();
  const year = today.getFullYear();
  const month = String(today.getMonth() + 1).padStart(2, "0");
  const day = String(today.getDate()).padStart(2, "0");
  const maxDateString = `${year}-${month}-${day}`;
Enter fullscreen mode Exit fullscreen mode

Then we can set max attribute of the input component as maxDateString. This won't allow us to select any date which comes after today.

Now let's say we want our users to be atleast 18 years old. We can implement this using the onChange callback. Add this onChange callback to the validators property.

onChange: ({ value }: { value: string }) => {
    if (!value) {
        return "Date is required";
    }

    const birthDate = new Date(value);
    const currentDate = new Date();

    if (isNaN(birthDate.getTime())) {
        return "Please enter a valid date";
    }

    const ageInDays = (currentDate.getTime() - birthDate.getTime()) /(1000 * 60 * 60 * 24);

    if (ageInDays < 18 * 365.25) {
        return "You must be older than 18";
    }

    return undefined;
},
}}

Enter fullscreen mode Exit fullscreen mode

This function first check whether the date is present or not and then converts them into date objects. Then it subtracts the time of the two dates which returns time in milliseconds. Now it is divided by to get the time in days. And then we compare it with the number of days in 18 years.

Now all our fields are done. Now we would like to disable our submit button when there are any errors. So replace your button component with this.

<form.Subscribe
    selector={(state) => [state.canSubmit, state.isSubmitting]}
    children={([canSubmit, isSubmitting]) => (
        <button
            type="submit"
            disabled={!canSubmit}
            className="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-150 ease-in-out"
                >
            {isSubmitting ? `...` : `Sign up`}
        </button>
      )}
/>
Enter fullscreen mode Exit fullscreen mode

The form.Subscribe component can listen to any changes in the form values and is used mostly for listening to the form and make UI changes according to it.

Now once you submit the form with correct values you can see that the form gets submitted and the values are logged into the console but we need to submit these values to the json-server. We can do it by changing our onSubmit callback in our form instance. We use axios to submit the data to the post endpoint of our json-server like this

onSubmit: async ({ value }) => {
      await axios.post(`http://localhost:3000/users`, value);
      alert("Signup successfull");
    }
Enter fullscreen mode Exit fullscreen mode

Now fill the form correctly and submit it. An alert will pop up with the message "Signup successfull". Now go to the db.json file and you will see a new user with the values you just filled up in the form.


Wrapping up !!

Congratulations on building your first type safe and performant form using tanstack form which has superb UX like real time validation and async email checks.

All of the above code is available on Github.

If you found this guide helpful let me know by dropping some cool comments and questions. I will be really happy to answer them. Follow for more such upcoming blogs and comment if you want a blog on a particular topic. Until then you can

Top comments (6)

Collapse
 
nabamallika_nath_3156c18d profile image
Nabamallika Nath

Very helpful

Collapse
 
parthiv_saikia_ profile image
PARTHIV SAIKIA

Thank you very much for your compliment.

Collapse
 
humayun_61ce37df8dc21865e profile image
Humayun

It was a nice read. Keep posting

Collapse
 
parthiv_saikia_ profile image
PARTHIV SAIKIA

Sure thanks for your comment humayun. Follow for more.

Collapse
 
jumon_fddc33421a8aa7f3eb7 profile image
Jumon

Really informative parthiv

Collapse
 
parthiv_saikia_ profile image
PARTHIV SAIKIA

Thank you very much