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
- Navigate to the project directory:
cd react-form
- Install project dependencies:
npm install
- Install additional required packages:
npm install joi-browser@latest react-bootstrap bootstrap
- Open the project in your preferred code editor (e.g., VSCode):
code .
- Start the development server:
npm run dev
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;
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;
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;
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');
Let's break down the schema:
-
Username:
- Must be a string
- Can only contain alphanumeric characters
- Must be between 3 and 30 characters long
- Is required
-
Password:
- Must be a string
- Must match the pattern: 3-30 alphanumeric characters
-
Repeat password:
- Must match the 'password' field
-
Access token:
- Can be either a string or a number
-
Birth year:
- Must be a number
- Must be an integer
- Must be between 1900 and 2013
-
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;
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
: ThevalidateForm
function uses the Joivalidate
method to get the errors from the form. Thevalidate
method receives 3 arguments, the form inputs stored in the formData state, the defined scheme, and the options object which has anabortEarly
property set to false. If theerror
object is empty, thevalidateForm
function returns null, else, it loops through theerror.details
array and fills the emptyerrors
object with the error messages found in the array. -
handleChange
: ThehandleChange
function updates theformData
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 theformData
object to the browser’s console. In this functione.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, thevalidateForm
function is called and its return is stored in the errors variable and then set to the errors state usingsetErrors
. 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;
}),
- 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' } } })
- 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()
})
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)