Validating React Forms easily without third-party libraries
Even with the advances regarding form validation in React 19, the sad truth is that validating a form is still a boring and repetitive task. The goal of this post is to share an approach that's versatile enough to work with any form while significantly reducing the effort and monotony of coding it.
This approach is based on three steps that run every time the form data gets updated:
Validation flow diagram for a form
The way responsibilities are split in this strategy, represented by the blocks A and B in the diagram above, lets you extract B into its own module, making it reusable across your entire app.
For future forms, you'll only need to define the context-related variables Form Data and Validations.
Structure
Form Data: State variable representing the form's data as an object with key/value pairs, where key is the name
of the form field:
const formData = {
[NAME]: '',
[EMAIL]: ''
};
Validations: Object with a similar structure to formData
, but instead of key/value pairs, it holds a list of validators and their corresponding error messages. The idea is that the value entered in the form field is checked against each validation, and if one fails, the related error message is shown next to the field:
const validations = {
[NAME]: [{
isValid: val => !!val,
errorMessage: 'Required'
}, {
isValid: val => val?.length >= 2,
errorMessage: 'Enter at least 2 characters'
}],
[EMAIL]: [{
isValid: val => !!val,
errorMessage: 'Required'
}, {
isValid: val => isEmailValid(val),
errorMessage: 'Enter a valid email address'
}]
};
function isEmailValid(email) {
const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return regex.test(email);
}
Validator: Function that takes formData
, runs it through the validations, and generates the formErrors
object:
export const validateForm = (formData, validations) => {
return Object
.entries(validations)
.reduce((formErrors, [fieldName, fieldValidations]) => {
const err = validateField(formData[fieldName], fieldValidations);
return err ? { ...formErrors, [fieldName]: err } : formErrors;
}, {});
}
function validateField(fieldValue, fieldValidations) {
return fieldValidations
.reduce((fieldError, { isValid, errorMessage }) => {
if (fieldError) return fieldError;
return !isValid(fieldValue) ? errorMessage : fieldError;
}, '');
}
Form Errors: Object with key/value pairs representing form errors. The key is the name
of the form field, and the value is the error message explaining what's wrong. Valid fields won't be included in formErrors
, so when the form is 100% valid, formErrors
will be an empty object ({}
):
const formErrors = {
[NAME]: 'Required',
[EMAIL]: 'Required'
};
The example above simulates a situation where none of the form fields were filled in.
Example
Here's how this approach looks when applied to a NewsletterForm
component, which has two required fields: Name and Email.
import { useState } from 'react';
import { validateForm } from './form-validator';
import { isEmailValid } from './email-validator';
const NAME_FIELD_NAME = 'name';
const EMAIL_FIELD_NAME = 'email';
const NewsletterForm = () => {
const [formData, setFormData] = useState({});
const [hasSubmitted, setSubmission] = useState();
const validations = buildValidations();
const formErrors = validateForm(formData, validations);
const handleChange = ({ target: { name, value } }) => {
setFormData({ ...formData, [name]: value });
};
const buildErrorMessageEl = fieldName => {
const message = formErrors[fieldName];
return hasSubmitted && message && <span>{message}</span>;
}
const handleSubmit = evt => {
evt.preventDefault();
setSubmission(true);
if (Object.keys(formErrors).length === 0) {
alert('Form submitted!');
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
aria-label="Name"
placeholder="Name"
name={NAME_FIELD_NAME}
value={formData[NAME_FIELD_NAME] || ''}
onChange={handleChange}
/>
{buildErrorMessageEl(NAME_FIELD_NAME)}
</div>
<div>
<input
aria-label="Email"
placeholder="Email"
name={EMAIL_FIELD_NAME}
value={formData[EMAIL_FIELD_NAME] || ''}
onChange={handleChange}
/>
{buildErrorMessageEl(EMAIL_FIELD_NAME)}
</div>
<button type="submit">Subscribe</button>
</form>
);
};
function buildValidations(){
return {
[NAME_FIELD_NAME]: [{
isValid: val => !!val,
errorMessage: 'Required'
}, {
isValid: val => val?.length >= 2,
errorMessage: 'Enter at least 2 characters'
}],
[EMAIL_FIELD_NAME]: [{
isValid: val => !!val,
errorMessage: 'Required'
}, {
isValid: val => isEmailValid(val),
errorMessage: 'Enter a valid email address'
}]
};
}
export default NewsletterForm;
Validation approach applied to a Newsletter form
Hands-on: In this Gist, you'll find an HTML file containing all the code you need to run the example locally.
Learn more: If you liked this post, you might also enjoy learning how to test and implement an in-page scroll link in React.