Form Validation Patterns Cheat Sheet
Covers HTML5 constraint validation attributes, the Constraint Validation API, React Hook Form, common regex patterns, and accessible error messaging.
HTML5 Validation Attributes
Native browser validation without any JavaScript.
- required- Marks a field mandatory; blocks form submission until it has a value
- pattern- Regex the value must match, e.g. pattern="[0-9]{5}" for a zip code
- minlength / maxlength- Enforces character count bounds on text/textarea inputs
- min / max- Enforces numeric or date range bounds on number/date/range inputs
- type="email" / "url"- Built-in format validation for email addresses and URLs
- step- Restricts numeric input to increments, e.g. step="0.01" for currency
- novalidate- Form attribute that disables native browser validation entirely
- :invalid / :valid- CSS pseudo-classes that match inputs based on current validity state
Constraint Validation API
Inspect and customize native validation from JavaScript.
const input = document.querySelector('#email');// Check validity without showing browser UIif (!input.checkValidity()) { console.log(input.validationMessage);}// Trigger the browser's native validation bubbleinput.reportValidity();// Add a custom rule on top of the built-in type="email" checkinput.addEventListener('input', () => { if (input.value.includes('+')) { input.setCustomValidity('Plus signs are not allowed'); } else { input.setCustomValidity(''); // must clear or the field stays invalid }});// ValidityState flags you can branch oninput.validity.valueMissing; // true if required and emptyinput.validity.typeMismatch; // true if type="email" but malformedinput.validity.patternMismatch; // true if pattern doesn't matchinput.validity.tooShort; // true if below minlength
React Hook Form
Schema-light form state and validation for React.
import { useForm } from 'react-hook-form';function SignupForm() { const { register, handleSubmit, formState: { errors } } = useForm(); const onSubmit = (data) => console.log(data); return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('email', { required: 'Email is required', pattern: { value: /^\S+@\S+\.\S+$/, message: 'Invalid email' } })} /> {errors.email && <span role="alert">{errors.email.message}</span>} <input type="password" {...register('password', { required: true, minLength: 8 })} /> <button type="submit">Sign up</button> </form> );}
Common Regex Patterns
Battle-tested patterns for everyday field validation.
- Email (simple)- /^[^\s@]+@[^\s@]+\.[^\s@]+$/ — good enough client-side, always re-check server-side
- US phone- /^\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/ — matches (123) 456-7890 or 123-456-7890
- URL- /^https?:\/\/\S+$/ — checks for a scheme, not that the host actually resolves
- Strong password- /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/ — one lower, upper, digit, symbol, 8+ chars
- US ZIP code- /^\d{5}(-\d{4})?$/ — matches 5-digit or ZIP+4 format
Accessible Error Messaging
Wire validation state to assistive technology correctly.
<label for="email">Email</label><input id="email" type="email" aria-invalid="true" aria-describedby="email-error"/><span id="email-error" role="alert"> Please enter a valid email address.</span>
Schema Validation with Zod + React Hook Form
Centralize validation rules in a single reusable schema instead of scattering register() rules across fields.
import { z } from 'zod';import { zodResolver } from '@hookform/resolvers/zod';import { useForm } from 'react-hook-form';const signupSchema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(8, 'At least 8 characters'), confirmPassword: z.string(), age: z.coerce.number().int().min(13, 'Must be 13 or older'),}).refine((data) => data.password === data.confirmPassword, { message: 'Passwords do not match', path: ['confirmPassword'], // attaches the error to this specific field});function SignupForm() { const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(signupSchema), mode: 'onBlur', }); return ( <form onSubmit={handleSubmit((data) => console.log(data))}> <input {...register('email')} /> {errors.email && <span role="alert">{errors.email.message}</span>} <button type="submit">Sign up</button> </form> );}
Async, Debounced Server-Side Field Validation
Check availability (e.g. a username) against an API without hammering it on every keystroke.
import { useForm } from 'react-hook-form';import { useRef } from 'react';function UsernameField() { const { register, formState: { errors } } = useForm({ mode: 'onChange' }); const debounceTimer = useRef(null); const checkAvailability = (value) => new Promise((resolve) => { clearTimeout(debounceTimer.current); debounceTimer.current = setTimeout(async () => { const res = await fetch(`/api/username-available?u=${encodeURIComponent(value)}`); const { available } = await res.json(); resolve(available || 'Username is already taken'); }, 400); // wait for the user to pause typing before hitting the network }); return ( <input {...register('username', { required: true, validate: checkAvailability, // RHF awaits any Promise-returning validate fn })} /> );}
Cross-Field & Conditional Validation
Validate one field's rules based on another field's current value.
import { useForm } from 'react-hook-form';function ShippingForm() { const { register, watch, formState: { errors } } = useForm({ defaultValues: { deliveryMethod: 'pickup', address: '' }, }); const deliveryMethod = watch('deliveryMethod'); return ( <form> <select {...register('deliveryMethod')}> <option value="pickup">Pickup</option> <option value="ship">Ship</option> </select> <input {...register('address', { // address is only required when shipping, not for pickup validate: (value) => deliveryMethod !== 'ship' || value.trim().length > 0 || 'Address is required for shipping', })} /> {errors.address && <span role="alert">{errors.address.message}</span>} </form> );}
Validation Timing & UX Patterns
When to run validation, beyond the naive "validate on every keystroke."
- Validate on blur, not on change- Avoids flashing errors mid-typing; switch to onChange only after the first error to give instant relief
- Progressive disclosure- Show detailed rule breakdowns (e.g. password requirements) only after the field is touched or fails
- Debounce async checks- Network-backed validators (username/email uniqueness) should wait ~300-500ms after the last keystroke
- Optimistic + reconciled state- Let submission proceed on plausible client-side data, reconcile against authoritative server errors after
- Field-level vs form-level errors- Surface field errors inline near the input; reserve form-level banners for cross-cutting/server failures
- Don't disable the submit button- Prefer letting users submit and see full errors over a permanently-disabled button with no feedback why
- Preserve valid input on error- Never clear a field's value because validation failed elsewhere in the form
File Upload Validation (Type, Size, Count)
Validate File objects before they ever leave the browser, using custom validity for native form integration.
const MAX_SIZE_MB = 5;const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];function validateFiles(fileList) { const files = Array.from(fileList); if (files.length > 3) return 'You can upload at most 3 files'; for (const file of files) { if (!ALLOWED_TYPES.includes(file.type)) { return `${file.name}: unsupported format`; } if (file.size > MAX_SIZE_MB * 1024 * 1024) { return `${file.name}: exceeds ${MAX_SIZE_MB}MB`; } } return true; // react-hook-form treats `true` as valid}// register('attachments', { validate: (fl) => validateFiles(fl) })// Native fallback: setCustomValidity ties the same check into :invalid/:validinput.addEventListener('change', () => { const msg = validateFiles(input.files); input.setCustomValidity(msg === true ? '' : msg);});
Never rely on client-side validation alone — it improves UX but can be bypassed entirely via DevTools or a direct API call, so always re-validate every field on the server before persisting it.