DEV Community

Cover image for How to Implement Form Validation with Joi in a React App
DrPrime01
DrPrime01

Posted on

How to Implement Form Validation with Joi in a React App

When writing web applications that require user input, it is crucial to ensure that the form used to receive users’ information is secure and adequately validated before its data is sent to the server. If your application’s form is not scrutinized, attackers can enter malicious scripts in its input box capable of stealing and using your users’ data. To avoid such scenarios, you’ll learn how to build secure React forms using your written validation script and Joi.

Setting up the Project

For this article, you’ll learn how to build a login form with React and style it with Bootstrap. Bootstrap is a CSS framework with ready-made components that you can use in your applications. Bootstrap has a variant for React projects—React Bootstrap, and you’ll use it in this application.

  • Create a new React application using Vite:


    npm create vite@latest react-form -- --template react


Enter fullscreen mode Exit fullscreen mode
  • Navigate to the project directory:


    cd react-form


Enter fullscreen mode Exit fullscreen mode
  • Install project dependencies:


    npm install


Enter fullscreen mode Exit fullscreen mode
  • Install additional required packages:


    npm install joi-browser@latest react-bootstrap bootstrap


Enter fullscreen mode Exit fullscreen mode
  • Open the project in your preferred code editor (e.g., VSCode):


    code .


Enter fullscreen mode Exit fullscreen mode
  • Start the development server:


    npm run dev


Enter fullscreen mode Exit fullscreen mode

Building the Login Form

In your project directory in VSCode, create a new folder in the src folder and name it components. In the components folder, create a SecureForm.jsx file. That’s where you’ll build the form.
For the form, you’ll use Bootstrap, not React-Bootstrap so head over to the Bootstrap website documentation and the forms section. React-Bootstrap forms are built in React components already, and you won’t have access to the input elements, let alone validate them, so you’ll use the HTML form variant on Bootstrap. There, you’ll get a basic example of the type of form you’ll create. To save you the stress, the form is copied here with minimal changes to suit your desired application.



    function SecureForm() {
      return (
        <form>
          <div className="mb-3">
            <label htmlFor="exampleInputEmail1" className="form-label">
              Email address
            </label>
            <input 
              type="email" 
              className="form-control" 
              id="exampleInputEmail1" 
              aria-describedby="emailHelp"
            />
            <div id="emailHelp" className="form-text">
              We'll never share your email with anyone else.
            </div>
          </div>
          <div className="mb-3">
            <label htmlFor="exampleInputPassword1" className="form-label">Password</label>
            <input 
              type="password" 
              className="form-control" 
              id="exampleInputPassword1"
            />
          </div>
          <button type="submit" className="btn btn-primary">Submit</button>
        </form>
      );
    }

    export default SecureForm;


Enter fullscreen mode Exit fullscreen mode

After creating your form component, create states to store the username and password. These are the states that you’ll validate with Joi.



    import { useState } from "react";

    function SecureForm() {
      const [username, setUsername] = useState("");
      const [password, setPassword] = useState("");
      const [error, setError] = useState({});

      const handleSubmit = (e) => {
        e.preventDefault();
        // submit function here
      }

      return (
        <form onSubmit={handleSubmit}>
          <div className="mb-3">
            <label htmlFor="exampleInputEmail1" className="form-label">
              Email address
            </label>
            <input 
              type="email" 
              className="form-control" 
              id="exampleInputEmail1" 
              aria-describedby="emailHelp" 
              value={username} 
              onChange={(e) => setUsername(e.target.value)} 
            />
            {error.username && <span className="text-danger">{error.username}</span>}
            <div id="emailHelp" className="form-text">
              We'll never share your email with anyone else.
            </div>
          </div>
          <div className="mb-3">
            <label htmlFor="exampleInputPassword1" className="form-label">Password</label>
            <input 
              type="password" 
              className="form-control" 
              id="exampleInputPassword1" 
              value={password} 
              onChange={(e) => setPassword(e.target.value)} 
            />
            {error.password && <span className="text-danger">{error.password}</span>}
          </div>
          <button type="submit" className="btn btn-primary">Submit</button>
        </form>
      );
    }

    export default SecureForm;


Enter fullscreen mode Exit fullscreen mode

Validating the Form Inputs

Let’s implement a simple validation function before using Joi.



    import { useState } from "react";

    function SecureForm() {
      const [username, setUsername] = useState("");
      const [password, setPassword] = useState("");
      const [error, setError] = useState({});

      const validateInput = (input) => {
        if (input.name === "username") {
          if (username.length === 0) return "Username is required!";
        }
        if (input.name === "password") {
          if (password.length === 0) return "Password is required!";
        }
      }

      const handleUsernameChange = (e) => {
        const input = e.target;
        setUsername(input.value);
        const inputErrors = {...error};
        const errorMessage = validateInput(input);
        if (errorMessage) {
          inputErrors.username = errorMessage;
        } else {
          delete inputErrors.username;
        }
        setError(inputErrors);
      }

      const handlePasswordChange = (e) => {
        const input = e.target;
        setPassword(input.value);
        const inputErrors = {...error};
        const errorMessage = validateInput(input);
        if (errorMessage) {
          inputErrors.password = errorMessage;
        } else {
          delete inputErrors.password;
        }
        setError(inputErrors);
      }

      const validateForm = () => {
        const errors = {};
        if (username.length === 0) {
          errors.username = "Username is required!";
        }
        if (password.length === 0) {
          errors.password = "Password is required!";
        }
        setError(errors);
        return Object.keys(errors).length === 0 ? null : errors;
      }

      const handleSubmit = (e) => {
        e.preventDefault();
        const errors = validateForm();
        if (errors) return;
        // submit the form to the endpoint
        console.log("Form submitted successfully!");
      }

      // ... rest of the component remains the same
    }

    export default SecureForm;


Enter fullscreen mode Exit fullscreen mode

This validation method approaches form validation from two different aspects for better security. First, there’s input validation, and then, form validation. Input validation validates the input on the fly. It immediately displays an error message if there’s an error in the error state object about the related input element. After thoroughly checking the input validation, the form validation becomes active once the user hits the submit button. The validate function checks if the username state is empty. If it is, a username property is created in the errors state object with a value of “Username is required!”. After the username check, a similar check happens with the password with the same result. At the end of the function, the validate function returns null if there’s no property in the errors state object, and returns the errors state object if an error exists.

Validating the form inputs with Joi

While the above method works, it can become cumbersome for more complex forms. This is where Joi comes in. Joi is a form-validating library used on the client side and server side. With Joi, you can define a schema object and pass it with the keys of the form inputs you want to validate while using Joi-defined validation methods as the value for each key. The code block below shows a simple validation schema from Joi’s webpage.



    const Joi = require('joi');

    const schema = Joi.object({
        username: Joi.string()
            .alphanum()
            .min(3)
            .max(30)
            .required(),

        password: Joi.string()
            .pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')),

        repeat_password: Joi.ref('password'),

        access_token: [
            Joi.string(),
            Joi.number()
        ],

        birth_year: Joi.number()
            .integer()
            .min(1900)
            .max(2013),

        email: Joi.string()
            .email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } })
    })
        .with('username', 'birth_year')
        .xor('password', 'access_token')
        .with('password', 'repeat_password');


Enter fullscreen mode Exit fullscreen mode

Let's break down the schema:

  1. Username:

    • Must be a string
    • Can only contain alphanumeric characters
    • Must be between 3 and 30 characters long
    • Is required
  2. Password:

    • Must be a string
    • Must match the pattern: 3-30 alphanumeric characters
  3. Repeat password:

    • Must match the 'password' field
  4. Access token:

    • Can be either a string or a number
  5. Birth year:

    • Must be a number
    • Must be an integer
    • Must be between 1900 and 2013
  6. Email:

    • Must be a valid email string
    • Must have at least 2 domain segments
    • Top-level domain must be either 'com' or 'net'

Additional rules:

  • If 'username' is provided, 'birth_year' must also be provided
  • Either 'password' or 'access_token' must be provided, but not both
  • If 'password' is provided, 'repeat_password' must also be provided

Joi proceeds to the next check if all requirements are met for the username. If not, it returns an error object stating what check failed.

For this article, we’ll create a login form with React and validate it with Joi.



    import React, { useState } from 'react';
    import Joi from 'joi-browser';

    function SecureForm() {
      const [formData, setFormData] = useState({
        username: '',
        password: ''
      });
      const [errors, setErrors] = useState({});

      // Define Joi schema
      const schema = {
        username: Joi.string().email().required().label('Username'),
        password: Joi.string().min(5).required().label('Password')
      };

      const validateForm = () => {
        const options = { abortEarly: false };
        const { error } = Joi.validate(formData, schema, options);

        if (!error) return null;

        const errors = {};
        for (let item of error.details) {
          errors[item.path[0]] = item.message;
        }
        return errors;
      };

      const handleChange = (e) => {
        setFormData((prev) => ({...prev, [e.target.name]: e.target.value})
      };

      const handleSubmit = (e) => {
        e.preventDefault();
        const errors = validateForm();
        setErrors(errors || {});
        if (errors) return;

        // Submit the form if it's valid
        console.log('Form submitted', formData);
      };

      return (
        <form onSubmit={handleSubmit}>
          <div className="mb-3">
            <label htmlFor="username" className="form-label">Email address</label>
            <input
              type="email"
              className="form-control"
              id="username"
              name="username"
              value={formData.username}
              onChange={handleChange}
            />
            {errors.username && <div className="text-danger">{errors.username}</div>}
          </div>
          <div className="mb-3">
            <label htmlFor="password" className="form-label">Password</label>
            <input
              type="password"
              className="form-control"
              id="password"
              name="password"
              value={formData.password}
              onChange={handleChange}
            />
            {errors.password && <div className="text-danger">{errors.password}</div>}
          </div>
          <button type="submit" className="btn btn-primary">Submit</button>
        </form>
      );
    }

    export default SecureForm;


Enter fullscreen mode Exit fullscreen mode

In this login form, we defined a Joi schema of username and password and created 2 states to handle the form data and the errors returned by the Joi schema. The SecureForm component has 3 primary functions: validateForm, handleChange, and handleSubmit.

  • validateForm: The validateForm function uses the Joi validate method to get the errors from the form. The validate method receives 3 arguments, the form inputs stored in the formData state, the defined scheme, and the options object which has an abortEarly property set to false. If the error object is empty, the validateForm function returns null, else, it loops through the error.details array and fills the empty errors object with the error messages found in the array.
  • handleChange: The handleChange function updates the formData state as the user types in the form input.
  • handleSubmit: This is the function that handles the form submission. In a real-world application, the API that sends the form data to the server is called here. For simplicity reasons, the SecureForm component logs a “Form Submitted” message and the formData object to the browser’s console. In this function e.preventDefault method is initially called to prevent the default action of a form submission in the browser from happening, which is a page reload. Afterwards, the validateForm function is called and its return is stored in the errors variable and then set to the errors state using setErrors. If errors exist, the function breaks and the error messages are displayed to the users underneath the input elements. If no errors, the submit logic, usually an API call, is executed.

Benefits of Using Joi Over Basic Validation

Looking at the 2 code blocks for form validations, the one with Joi and the other without it, one can easily deduce the benefits of using Joi over basic validation. Joi gives you more robust and flexible validation rules in a few lines of code making your code simpler and easier to read and understand. With Joi, you can easily upscale and maintain additional validation inputs without breaking existing functionalities. Also, Joi equips you with built-in error messages and more customisable validation methods to your application’s needs. In a basic form validation, you’ll spend more time trying to achieve what Joi does, at the expense of introducing bugs to your code. With Joi, you can save time and configure your form’s validation to your needs.

Additional Joi Features

Joi provides custom validation messages for your form, conditional validation for special use cases, arrays and object validation if you’re using a select element with multiple options.

  • Custom Error Messages: You can provide custom error messages for each validation rule:


    username: Joi.string()
      .email()
      .required()
      .label('Username')
      .error(errors => {
        errors.forEach(err => {
          switch (err.type) {
            case "string.email":
              err.message = "Username must be a valid email";
              break;
            case "any.required":
              err.message = "Username is required";
              break;
            default:
              break;
          }
        });
        return errors;
      }),


Enter fullscreen mode Exit fullscreen mode
  • Conditional Validation: You can add conditional validation based on other fields:


    password: Joi.string().min(5).required(),
    confirmPassword: Joi.string().valid(Joi.ref('password')).required()
      .options({ language: { any: { allowOnly: 'must match password' } } })


Enter fullscreen mode Exit fullscreen mode
  • Array and Object Validation: Joi can handle complex data structures:


    hobbies: Joi.array().items(Joi.string()),
    address: Joi.object({
      street: Joi.string().required(),
      city: Joi.string().required(),
      zipCode: Joi.string().regex(/^\d{5}$/).required()
    })


Enter fullscreen mode Exit fullscreen mode

In addition, you can configure Joi with external form libraries like react-hook-form or even React component form libraries like MUI (fka, MaterialUI).

Key Best Practices for Form Validation in React

  • Validate on both client and server-side
  • Provide immediate feedback to users
  • Use clear and specific error messages
  • Handle all possible input scenarios
  • Ensure form accessibility (use proper ARIA attributes, ensure keyboard navigation)

For more in-depth best practices, refer to resources like React's accessibility documentation and web accessibility guidelines (WCAG).

Conclusion

In this article, we've explored how to implement robust form validation in React applications using Joi. We started by setting up a basic React form and then progressively enhanced it with custom validation and finally with Joi validation.
Key takeaways:

  • Form validation is crucial for ensuring data integrity and improving user experience.
  • Joi provides a powerful, flexible system for defining validation schemas.
  • Implementing validation in React requires careful state management and error handling.
  • Best practices include validating at appropriate times (on change, blur, and submit), handling async validation gracefully, and ensuring accessibility.

By following these practices and leveraging libraries like Joi, you can create forms that are not only functional but also user-friendly and accessible. Remember that form validation is not only about preventing errors but also guiding users to complete their tasks.
As you continue to develop your React applications, consider exploring more advanced topics such as form state management libraries (e.g., Formik, React Hook Form), more complex Joi schemas, and integrating your front-end validation with back-end API validations.
Implementing effective form validation is an ongoing process of refinement and improvement. As you gather user feedback and analyze form completions, you can fine-tune your validation strategies to create even better user experiences.

Top comments (0)