Form Validation
Submitting invalid data is one of the most common sources of bugs and poor user experiences. React gives you full control over validation logic — when to validate, what rules to apply, and how to display errors. This page covers building a robust client-side validation system from scratch.
HTML5 Native Validation vs Custom Validation
Browsers provide built-in validation via attributes like required, minLength, type="email", and pattern. This is zero-effort but has significant limitations:
Styling is browser-dependent and hard to customize
Error messages appear only on submit, not as the user types
You cannot cross-validate fields (e.g. "password must match confirm password")
Accessibility varies across browsers
You cannot programmatically control when validation runs
Custom React validation gives you complete control. Most production applications combine both: use type="email" for the browser keyboard hint on mobile, but rely on custom logic for the actual error display.
When to Validate
Strategy | Trigger | Best For |
|---|---|---|
Submit-time | User clicks Submit | Simple forms, minimal interruption |
Blur-time (onBlur) | User leaves a field | Balance between feedback and annoyance |
Change-time (onChange) | User types in a field | Real-time feedback (password strength, etc.) |
Hybrid | Blur first, then onChange once touched | Production forms — industry standard |
The Error State Object
Mirror your form state with an errors object that has the same keys. Each key maps to either an error string or undefined:
const [form, setForm] = useState({ email: '', password: '' })
const [errors, setErrors] = useState({ email: '', password: '' })
// 'errors.email' is either '' (no error) or a message like 'Email is required'A Validation Function
Keep validation logic in a pure function that takes form values and returns an errors object. This makes it easy to test and reuse:
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function validate(values) {
const errors = {}
if (!values.firstName.trim()) {
errors.firstName = 'First name is required.'
}
if (!values.email.trim()) {
errors.email = 'Email is required.'
} else if (!EMAIL_REGEX.test(values.email)) {
errors.email = 'Please enter a valid email address.'
}
if (!values.password) {
errors.password = 'Password is required.'
} else if (values.password.length < 8) {
errors.password = 'Password must be at least 8 characters.'
} else if (!/[A-Z]/.test(values.password)) {
errors.password = 'Password must contain at least one uppercase letter.'
}
if (values.confirmPassword !== values.password) {
errors.confirmPassword = 'Passwords do not match.'
}
return errors
}
// A form is valid when the errors object has no keys:
const isValid = Object.keys(validate(form)).length === 0Submit-Time Validation
import { useState } from 'react'
function SignupForm() {
const [form, setForm] = useState({ email: '', password: '' })
const [errors, setErrors] = useState({})
function handleChange(e) {
const { name, value } = e.target
setForm(prev => ({ ...prev, [name]: value }))
}
function handleSubmit(e) {
e.preventDefault()
const validationErrors = validate(form)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return // stop here — do not submit
}
// All good — submit to the API
setErrors({})
console.log('Submitting', form)
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<input
name="email"
type="email"
value={form.email}
onChange={handleChange}
aria-describedby="email-error"
aria-invalid={!!errors.email}
/>
{errors.email && (
<span id="email-error" role="alert" style={{ color: 'red' }}>
{errors.email}
</span>
)}
</div>
<div>
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
aria-describedby="password-error"
aria-invalid={!!errors.password}
/>
{errors.password && (
<span id="password-error" role="alert" style={{ color: 'red' }}>
{errors.password}
</span>
)}
</div>
<button type="submit">Sign Up</button>
</form>
)
}Hybrid Validation: Blur Then Change
The best UX pattern: show errors only after a field has been touched (blurred at least once), then update errors in real time as the user corrects them. This avoids screaming red errors the moment someone opens the form.
import { useState } from 'react'
function SignupForm() {
const [form, setForm] = useState({ firstName: '', email: '', password: '' })
const [errors, setErrors] = useState({})
const [touched, setTouched] = useState({})
function handleChange(e) {
const { name, value } = e.target
setForm(prev => ({ ...prev, [name]: value }))
// Re-validate this field if it has already been touched
if (touched[name]) {
const newErrors = validate({ ...form, [name]: value })
setErrors(prev => ({
...prev,
[name]: newErrors[name] ?? '',
}))
}
}
function handleBlur(e) {
const { name } = e.target
setTouched(prev => ({ ...prev, [name]: true }))
// Validate the field the moment the user leaves it
const newErrors = validate(form)
setErrors(prev => ({ ...prev, [name]: newErrors[name] ?? '' }))
}
function handleSubmit(e) {
e.preventDefault()
// Mark all fields as touched so errors show even for untouched fields
setTouched({ firstName: true, email: true, password: true })
const validationErrors = validate(form)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return
}
console.log('Valid! Submitting:', form)
}
return (
<form onSubmit={handleSubmit} noValidate>
<FieldGroup
label="First name"
name="firstName"
value={form.firstName}
error={errors.firstName}
onChange={handleChange}
onBlur={handleBlur}
/>
<FieldGroup
label="Email"
name="email"
type="email"
value={form.email}
error={errors.email}
onChange={handleChange}
onBlur={handleBlur}
/>
<FieldGroup
label="Password"
name="password"
type="password"
value={form.password}
error={errors.password}
onChange={handleChange}
onBlur={handleBlur}
/>
<button type="submit">Sign Up</button>
</form>
)
}
// Reusable field component with accessible error display
function FieldGroup({ label, name, type = 'text', value, error, onChange, onBlur }) {
const errorId = `${name}-error`
return (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
onBlur={onBlur}
aria-describedby={error ? errorId : undefined}
aria-invalid={!!error}
style={{ borderColor: error ? 'red' : undefined }}
/>
{error && (
<span id={errorId} role="alert" style={{ color: 'red', fontSize: '0.875rem' }}>
{error}
</span>
)}
</div>
)
}Accessibility: Linking Errors to Inputs
Screen readers need to know that an error message is associated with an input. Use two attributes together:
aria-describedby="error-id"on the input — links to the error elementaria-invalid="true"on the input — announces the invalid staterole="alert"on the error span — announces the message immediately when it appearsidon the error span — must match thearia-describedbyvalue
<input
id="email"
name="email"
type="email"
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}Password Strength Indicator
Real-time feedback on password quality is a good use of change-time validation:
function getPasswordStrength(password) {
if (password.length === 0) return { label: '', score: 0 }
let score = 0
if (password.length >= 8) score++
if (/[A-Z]/.test(password)) score++
if (/[0-9]/.test(password)) score++
if (/[^A-Za-z0-9]/.test(password)) score++ // special characters
const labels = ['', 'Weak', 'Fair', 'Good', 'Strong']
const colors = ['', 'red', 'orange', 'goldenrod', 'green']
return { label: labels[score], color: colors[score], score }
}
// In the component:
const strength = getPasswordStrength(form.password)
// In the JSX:
<div style={{ color: strength.color, fontWeight: 600 }}>
{strength.label && `Strength: ${strength.label}`}
</div>