Form validation is a critical part of user interaction. This article explores various techniques and best practices for frontend form validation.
Validation Strategies
Validation Timing
Validation Timing Options:
┌─────────────────────────────────────────────────────┐
│ │
│ onBlur (on field exit) │
│ └── Validate when user leaves field, balanced │
│ │
│ onChange (on input) │
│ └── Real-time feedback, but may be too frequent │
│ │
│ onSubmit (on form submit) │
│ └── Unified validation, but delayed feedback │
│ │
│ Hybrid Strategy │
│ └── First onBlur, then onChange │
│ │
└─────────────────────────────────────────────────────┘
| Strategy | Pros | Cons |
|---|---|---|
| onBlur | Balanced experience | Slight delay |
| onChange | Instant feedback | Can be annoying |
| onSubmit | Unified handling | Delayed feedback |
HTML5 Native Validation
Validation Attributes
<!-- Required field -->
<input type="text" name="username" required />
<!-- Length limits -->
<input type="text" minlength="3" maxlength="20" />
<!-- Number range -->
<input type="number" min="0" max="100" step="1" />
<!-- Pattern matching -->
<input
type="text"
pattern="[A-Za-z0-9]+"
title="Only letters and numbers allowed"
/>
<!-- Email validation -->
<input type="email" required />
<!-- URL validation -->
<input type="url" placeholder="https://example.com" />
<!-- Phone number -->
<input type="tel" pattern="[0-9]{11}" />
Custom Validation Messages
<form id="myForm">
<input
type="email"
id="email"
required
oninvalid="this.setCustomValidity('Please enter a valid email address')"
oninput="this.setCustomValidity('')"
/>
<button type="submit">Submit</button>
</form>
// JavaScript custom validation
const emailInput = document.getElementById('email') as HTMLInputElement;
emailInput.addEventListener('invalid', (e) => {
const input = e.target as HTMLInputElement;
if (input.validity.valueMissing) {
input.setCustomValidity('Email address is required');
} else if (input.validity.typeMismatch) {
input.setCustomValidity('Please enter a valid email format');
}
});
emailInput.addEventListener('input', (e) => {
(e.target as HTMLInputElement).setCustomValidity('');
});
CSS Validation States
/* Valid state */
input:valid {
border-color: #10b981;
}
/* Invalid state */
input:invalid {
border-color: #ef4444;
}
/* Invalid after user interaction */
input:user-invalid {
border-color: #ef4444;
background-color: #fef2f2;
}
/* Required fields */
input:required {
border-left: 3px solid #3b82f6;
}
/* Optional fields */
input:optional {
border-left: 3px solid #9ca3af;
}
/* In/out of range */
input:in-range {
border-color: #10b981;
}
input:out-of-range {
border-color: #ef4444;
}
JavaScript Validation
Validation Functions
// Validation rule type
type ValidationRule = {
validate: (value: string) => boolean;
message: string;
};
// Common validation rules
const validators = {
required: (message = 'This field is required'): ValidationRule => ({
validate: (value) => value.trim().length > 0,
message,
}),
email: (message = 'Please enter a valid email address'): ValidationRule => ({
validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
message,
}),
minLength: (min: number, message?: string): ValidationRule => ({
validate: (value) => value.length >= min,
message: message || `At least ${min} characters required`,
}),
maxLength: (max: number, message?: string): ValidationRule => ({
validate: (value) => value.length <= max,
message: message || `Maximum ${max} characters allowed`,
}),
pattern: (regex: RegExp, message: string): ValidationRule => ({
validate: (value) => regex.test(value),
message,
}),
match: (fieldName: string, getValue: () => string, message?: string): ValidationRule => ({
validate: (value) => value === getValue(),
message: message || `Must match ${fieldName}`,
}),
};
// Validate field
function validateField(value: string, rules: ValidationRule[]): string[] {
const errors: string[] = [];
for (const rule of rules) {
if (!rule.validate(value)) {
errors.push(rule.message);
}
}
return errors;
}
// Usage
const emailErrors = validateField(email, [
validators.required(),
validators.email(),
]);
Form Validator Class
type FieldConfig = {
rules: ValidationRule[];
element: HTMLInputElement;
};
type FormErrors = Record<string, string[]>;
class FormValidator {
private fields: Map<string, FieldConfig> = new Map();
private errors: FormErrors = {};
addField(name: string, element: HTMLInputElement, rules: ValidationRule[]) {
this.fields.set(name, { element, rules });
// Bind events
element.addEventListener('blur', () => this.validateField(name));
element.addEventListener('input', () => {
if (this.errors[name]?.length) {
this.validateField(name);
}
});
}
validateField(name: string): boolean {
const field = this.fields.get(name);
if (!field) return true;
const { element, rules } = field;
const errors = validateField(element.value, rules);
this.errors[name] = errors;
this.updateFieldUI(name, errors);
return errors.length === 0;
}
validateAll(): boolean {
let isValid = true;
this.fields.forEach((_, name) => {
if (!this.validateField(name)) {
isValid = false;
}
});
return isValid;
}
private updateFieldUI(name: string, errors: string[]) {
const field = this.fields.get(name);
if (!field) return;
const { element } = field;
const errorContainer = document.getElementById(`${name}-error`);
if (errors.length > 0) {
element.classList.add('is-invalid');
element.classList.remove('is-valid');
if (errorContainer) {
errorContainer.textContent = errors[0];
errorContainer.style.display = 'block';
}
} else {
element.classList.remove('is-invalid');
element.classList.add('is-valid');
if (errorContainer) {
errorContainer.style.display = 'none';
}
}
}
getErrors(): FormErrors {
return { ...this.errors };
}
}
React Form Validation
React Hook Form
import { useForm } from 'react-hook-form';
interface FormData {
email: string;
password: string;
confirmPassword: string;
}
function RegistrationForm() {
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useForm<FormData>();
const password = watch('password');
const onSubmit = async (data: FormData) => {
await registerUser(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email address',
},
})}
aria-invalid={errors.email ? 'true' : 'false'}
/>
{errors.email && (
<span role="alert">{errors.email.message}</span>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
pattern: {
value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: 'Password must contain uppercase, lowercase and number',
},
})}
/>
{errors.password && (
<span role="alert">{errors.password.message}</span>
)}
</div>
<div>
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword', {
required: 'Please confirm password',
validate: (value) =>
value === password || 'Passwords do not match',
})}
/>
{errors.confirmPassword && (
<span role="alert">{errors.confirmPassword.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Register'}
</button>
</form>
);
}
Zod Schema Validation
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Define Schema
const registrationSchema = z
.object({
email: z
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'Password must contain uppercase, lowercase and number'
),
confirmPassword: z.string().min(1, 'Please confirm password'),
age: z
.number()
.min(18, 'Must be at least 18 years old')
.max(120, 'Invalid age'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type RegistrationData = z.infer<typeof registrationSchema>;
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegistrationData>({
resolver: zodResolver(registrationSchema),
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields */}
</form>
);
}
Accessible Validation
ARIA Attributes
<div class="form-group">
<label for="email" id="email-label">
Email Address
<span aria-hidden="true">*</span>
</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-labelledby="email-label"
aria-describedby="email-hint email-error"
aria-invalid="false"
/>
<span id="email-hint" class="hint">
We won't share your email
</span>
<span id="email-error" class="error" role="alert" aria-live="polite">
<!-- Dynamic error message -->
</span>
</div>
Screen Reader Friendly
interface FormFieldProps {
id: string;
label: string;
error?: string;
hint?: string;
required?: boolean;
children: React.ReactNode;
}
function FormField({
id,
label,
error,
hint,
required,
children,
}: FormFieldProps) {
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const describedBy = [hint && hintId, error && errorId]
.filter(Boolean)
.join(' ');
return (
<div className="form-field">
<label htmlFor={id}>
{label}
{required && <span aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (required)</span>}
</label>
{React.cloneElement(children as React.ReactElement, {
id,
'aria-required': required,
'aria-invalid': !!error,
'aria-describedby': describedBy || undefined,
})}
{hint && (
<span id={hintId} className="hint">
{hint}
</span>
)}
{error && (
<span id={errorId} className="error" role="alert" aria-live="polite">
{error}
</span>
)}
</div>
);
}
// Usage
<FormField
id="email"
label="Email"
required
error={errors.email?.message}
hint="We won't share your email"
>
<input type="email" {...register('email')} />
</FormField>
Best Practices Summary
Form Validation Best Practices:
┌─────────────────────────────────────────────────────┐
│ │
│ User Experience │
│ ├── Instant but non-intrusive feedback │
│ ├── Clear error messages │
│ ├── Show valid state confirmation │
│ └── Preserve user input │
│ │
│ Technical Implementation │
│ ├── Client + server dual validation │
│ ├── Use validation libraries │
│ ├── Define reusable validation rules │
│ └── Handle async validation │
│ │
│ Accessibility │
│ ├── Use correct ARIA attributes │
│ ├── Associate error messages with fields │
│ ├── Support keyboard navigation │
│ └── Screen reader friendly │
│ │
└─────────────────────────────────────────────────────┘
| Scenario | Recommendation |
|---|---|
| Simple forms | HTML5 native validation |
| Complex forms | React Hook Form + Zod |
| Real-time validation | debounce + async validation |
| Cross-field validation | Schema-level validation |
Good form validation is the guarantee of successful user submission.