Introduction
ommon cases (required fields, email format, number ranges) without JavaScript. For complex cases, you add JavaScript on top.
Validation should happen on both client and server. Client-side validation improves UX; server-side validation is the security boundary.
HTML5 Constraint Validation
The browser validates automatically when you use the right input types and attributes:
<form>
<!-- Required field -->
<input type="text" name="name" required>
<!-- Email format -->
<inpu" name="email" required>
<!-- URL format -->
<input type="url" name="website">
<!-- Number with range -->
<input type="number" name="age" min="18" max="120" required>
<!-- Password with minimum length -->
<input type="password" name="password" minlength="8" required>
<!-- Pattern matching -->
<input type="text" name="zipcode" pattern="[0-9]{5}" title="5-digit ZIP code">
<!-- Phone number -->
<input type="tel" name="phone" pattern="[0-9]{10,11}">
<button type="submit">Submit</button>
</form>
The browser shows error messages automatically when the form is submitted. No JavaScript needed for basic validation.
The Constraint Validation API
Use JavaScript to check validity programmatically and customize error messages:
const form = document.getElementById('myForm');
const emailInput = document.getElementById('email');
// Check if a field is valid
emailInput.checkValidity() // returns true/false
// Get the validation state
emailInput.validity.valueMissing // true if required and empty
emailInput.validity.typeMismatch // true if wrong type (e.g., not an email)
emailInput.validity.patternMismatch // true if pattern doesn't match
emailInput.validShort // true if below minlength
emailInput.validity.tooLong // true if above maxlength
emailInput.validity.rangeUnderflow // true if below min
emailInput.validity.rangeOverflow // true if above max
emailInput.validity.valid // true if all constraints pass
// Set a custom error message
emailInput.setCustomValidity('Please enter a company email address');
emailInput.setCustomValidity(''); // clear the error
Custom Validation with Inline Error Messages
Show errors next to each field instead of browser popups:
<form id="contactForm" novalidate>
<div class="field">
<label for="name">Name *</label>
<input id="name" type="text" required minlength="2">
<span class="error" aria-live="polite"></span>
</div>
<div class="field">
<label for="email">Email *</label>
<input id="email" type="email" required>
<span class="error" aria-live="polite"></span>
</div>
<div class="field">
<label for="phone">Phone</label>
<tern="[0-9]{10,11}">
<span class="error" aria-live="polite"></span>
</div>
<button type="submit">Submit</button>
</form>
// form-validation.js
const form = document.getElementById('contactForm');
// Custom error messages per validation type
const errorMessages = {
valueMissing: 'This field is required.',
typeMismatch: {
email: 'Please enter a valid email address.',
url: 'Please enter a valid URL.',
},
patternMismatch:d format.',
tooShort: (input) => `Minimum ${input.minLength} characters required.`,
tooLong: (input) => `Maximum ${input.maxLength} characters allowed.`,
rangeUnderflow: (input) => `Minimum value is ${input.min}.`,
rangeOverflow: (input) => `Maximum value is ${input.max}.`,
};
function getErrorMessage(input) {
const validity = input.validity;
if (validity.valueMissing) return errorMessages.valueMissing;
if (validmatch[input.type] || 'Invalid format.';
if (validity.patternMismatch) return input.title || errorMessages.patternMismatch;
if (validity.tooShort) return errorMessages.tooShort(input);
if (validity.tooLong) return errorMessages.tooLong(input);
if (validity.rangeUnderflow) return errorMessages.rangeUnderflow(input);
if (validity.rangeOverflow) return errorMessages.rangeOverflow(input);
return 'Invalid value.';
}
function showError(input, message) {
const errorEl = input.nextElementSibling;
input.setAttribute('aria-invalid', 'true');
errorEl.textContent = message;
errorEl.style.display = 'block';
}
function clearError(input) {
const errorEl = input.nextElementSibling;
input.removeAttribute('aria-invalid');
errorEl.textContent = '';
errorEl.style.display = 'none';
}
sendContactEmail(req.body);
res.json({ success: true });
}
);
Resources
- MDN: Client-side form validation
- MDN: Constraint Validation API
- React Hook Form
- Zod
- express-validator cript // Express.js with express-validator import { body, validationResult } from ’express-validator';
app.post(’/api/contact’, body(’name’).trim().isLength({ min: 2, max: 50 }).escape(), body(’email’).isEmail().normalizeEmail(), body(‘message’).trim().isLength({ min: 10, max: 1000 }).escape(),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
// Process valid data
valid email address.
```Server-Side Validation (Always Required)
Client-side validation can be bypassed. Always validate on the server:
Accessibility Requirements
Form validation must be accessible:
<!-- Associate error messages with inputs -->
<input
id="email"
type="email"
aria-describedby="email-error"
aria-invalid="true"
required
>
<span id="email-error" role="alert">
Please enter a const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
});
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register('name')} />
{errors.name && <p>{errors.name.message}</p>}
<input type="email" {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register('password')} />
{errors.password && <p>{errors.passwoz.string().min(2, 'Minimum 2 characters').max(50),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be 18 or older').max(120),
password: z.string()
.min(8, 'Minimum 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[0-9]/, 'Must contain a number'),
confirm: z.string(),
}).refine(data => data.password === data.confirm, {
message: "Passwords don't match",
path: ['confirm'],
});
function SignupForm() {
ole="alert">{errors.email.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Submit'}
</button>
</form>
);
}
With Zod Schema Validation
npm install react-hook-form @hookform/resolvers zod
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
name: id="email"
type="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
aria-invalid={errors.email ? 'true' : 'false'}
/>
{errors.email && (
<span r..register('name', {
required: 'Name is required',
minLength: { value: 2, message: 'Minimum 2 characters' },
})}
aria-invalid={errors.name ? 'true' : 'false'}
/>
{errors.name && (
<span role="alert">{errors.name.message}</span>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
it,
formState: { errors, isSubmitting },
} = useForm();
const onSubmit = async (data) => {
await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
{.earError(usernameInput);
}
} catch (err) {
console.error('Username check failed:', err);
}
}, 500);
usernameInput.addEventListener('input', (e) => {
usernameInput.setCustomValidity(''); // clear while typing
checkUsername(e.target.value);
});
React Form Validation
With React Hook Form (Recommended)
npm install react-hook-form
import { useForm } from 'react-hook-form';
function ContactForm() {
const {
register,
handleSubmconst checkUsername = debounce(async (username) => {
if (username.length < 3) return;
try {
const response = await fetch(`/api/check-username?username=${encodeURIComponent(username)}`);
const { available } = await response.json();
if (!available) {
usernameInput.setCustomValidity('This username is already taken.');
showError(usernameInput, 'This username is already taken.');
} else {
usernameInput.setCustomValidity('');
cl
.field input:valid:not(:placeholder-shown) {
border-color: #28a745;
}
.error {
display: none;
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
}
Async Validation (Check Username Availability)
// Debounce to avoid too many API calls
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const usernameInput = document.getElementById('username');
lectorAll('input, textarea, select');
let isValid = true;
inputs.forEach(input => {
if (!validateField(input)) {
isValid = false;
}
});
if (isValid) {
submitForm(new FormData(form));
} else {
// Focus the first invalid field
form.querySelector('[aria-invalid="true"]')?.focus();
}
});
/* form-validation.css */
.field {
margin-bottom: 1rem;
}
.field input:invalid:not(:placeholder-shown) {
border-color: #dc3545;
} return true;
}
// Validate on blur (when user leaves a field)
form.querySelectorAll('input, textarea, select').forEach(input => {
input.addEventListener('blur', () => validateField(input));
input.addEventListener('input', () => {
if (input.getAttribute('aria-invalid')) {
validateField(input); // re-validate as user types to clear errors
}
});
});
// Validate all on submit
form.addEventListener('submit', (e) => {
e.preventDefault();
const inputs = form.querySefunction validateField(input) {
if (!input.checkValidity()) {
showError(input, getErrorMessage(input));
return false;
}
clearError(input);
Comments