Back to Blog
ReactFormsValidationUX

Form Validation Patterns in React

Validate forms effectively. From native validation to libraries to real-time feedback patterns.

B
Bootspring Team
Engineering
May 5, 2022
6 min read

Good form validation improves user experience. Here's how to implement validation patterns effectively.

Native HTML Validation#

1// Built-in browser validation 2function SimpleForm() { 3 const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { 4 e.preventDefault(); 5 const formData = new FormData(e.currentTarget); 6 console.log(Object.fromEntries(formData)); 7 }; 8 9 return ( 10 <form onSubmit={handleSubmit}> 11 <input 12 type="email" 13 name="email" 14 required 15 placeholder="Email" 16 /> 17 18 <input 19 type="password" 20 name="password" 21 required 22 minLength={8} 23 pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}" 24 title="Must contain uppercase, lowercase, and number" 25 /> 26 27 <input 28 type="tel" 29 name="phone" 30 pattern="[0-9]{10}" 31 placeholder="Phone (optional)" 32 /> 33 34 <button type="submit">Submit</button> 35 </form> 36 ); 37}

React Hook Form#

1import { useForm } from 'react-hook-form'; 2import { zodResolver } from '@hookform/resolvers/zod'; 3import { z } from 'zod'; 4 5const schema = z.object({ 6 email: z.string().email('Invalid email'), 7 password: z 8 .string() 9 .min(8, 'Password must be at least 8 characters') 10 .regex(/[A-Z]/, 'Must contain uppercase letter') 11 .regex(/[0-9]/, 'Must contain number'), 12 confirmPassword: z.string(), 13}).refine((data) => data.password === data.confirmPassword, { 14 message: 'Passwords do not match', 15 path: ['confirmPassword'], 16}); 17 18type FormData = z.infer<typeof schema>; 19 20function RegistrationForm() { 21 const { 22 register, 23 handleSubmit, 24 formState: { errors, isSubmitting }, 25 } = useForm<FormData>({ 26 resolver: zodResolver(schema), 27 }); 28 29 const onSubmit = async (data: FormData) => { 30 await registerUser(data); 31 }; 32 33 return ( 34 <form onSubmit={handleSubmit(onSubmit)}> 35 <div> 36 <input 37 {...register('email')} 38 type="email" 39 placeholder="Email" 40 /> 41 {errors.email && ( 42 <span className="error">{errors.email.message}</span> 43 )} 44 </div> 45 46 <div> 47 <input 48 {...register('password')} 49 type="password" 50 placeholder="Password" 51 /> 52 {errors.password && ( 53 <span className="error">{errors.password.message}</span> 54 )} 55 </div> 56 57 <div> 58 <input 59 {...register('confirmPassword')} 60 type="password" 61 placeholder="Confirm Password" 62 /> 63 {errors.confirmPassword && ( 64 <span className="error">{errors.confirmPassword.message}</span> 65 )} 66 </div> 67 68 <button type="submit" disabled={isSubmitting}> 69 {isSubmitting ? 'Submitting...' : 'Register'} 70 </button> 71 </form> 72 ); 73}

Custom Validation Hook#

1import { useState, useCallback } from 'react'; 2 3interface ValidationRule<T> { 4 validate: (value: T) => boolean; 5 message: string; 6} 7 8interface FieldConfig<T> { 9 initialValue: T; 10 rules: ValidationRule<T>[]; 11} 12 13function useFormValidation<T extends Record<string, any>>( 14 config: { [K in keyof T]: FieldConfig<T[K]> } 15) { 16 const [values, setValues] = useState<T>(() => { 17 const initial = {} as T; 18 for (const key in config) { 19 initial[key] = config[key].initialValue; 20 } 21 return initial; 22 }); 23 24 const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({}); 25 const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({}); 26 27 const validateField = useCallback( 28 (name: keyof T, value: T[keyof T]): string | null => { 29 const rules = config[name].rules; 30 31 for (const rule of rules) { 32 if (!rule.validate(value)) { 33 return rule.message; 34 } 35 } 36 37 return null; 38 }, 39 [config] 40 ); 41 42 const setValue = useCallback( 43 (name: keyof T, value: T[keyof T]) => { 44 setValues((prev) => ({ ...prev, [name]: value })); 45 46 if (touched[name]) { 47 const error = validateField(name, value); 48 setErrors((prev) => ({ ...prev, [name]: error || undefined })); 49 } 50 }, 51 [touched, validateField] 52 ); 53 54 const setFieldTouched = useCallback( 55 (name: keyof T) => { 56 setTouched((prev) => ({ ...prev, [name]: true })); 57 const error = validateField(name, values[name]); 58 setErrors((prev) => ({ ...prev, [name]: error || undefined })); 59 }, 60 [values, validateField] 61 ); 62 63 const validate = useCallback((): boolean => { 64 const newErrors: Partial<Record<keyof T, string>> = {}; 65 let isValid = true; 66 67 for (const name in config) { 68 const error = validateField(name, values[name]); 69 if (error) { 70 newErrors[name] = error; 71 isValid = false; 72 } 73 } 74 75 setErrors(newErrors); 76 return isValid; 77 }, [config, values, validateField]); 78 79 return { 80 values, 81 errors, 82 touched, 83 setValue, 84 setFieldTouched, 85 validate, 86 }; 87} 88 89// Usage 90function ContactForm() { 91 const { values, errors, touched, setValue, setFieldTouched, validate } = 92 useFormValidation({ 93 name: { 94 initialValue: '', 95 rules: [ 96 { validate: (v) => v.length > 0, message: 'Name is required' }, 97 { validate: (v) => v.length >= 2, message: 'Name too short' }, 98 ], 99 }, 100 email: { 101 initialValue: '', 102 rules: [ 103 { validate: (v) => v.length > 0, message: 'Email is required' }, 104 { validate: (v) => v.includes('@'), message: 'Invalid email' }, 105 ], 106 }, 107 }); 108 109 const handleSubmit = (e: React.FormEvent) => { 110 e.preventDefault(); 111 if (validate()) { 112 submitForm(values); 113 } 114 }; 115 116 return ( 117 <form onSubmit={handleSubmit}> 118 <input 119 value={values.name} 120 onChange={(e) => setValue('name', e.target.value)} 121 onBlur={() => setFieldTouched('name')} 122 /> 123 {touched.name && errors.name && <span>{errors.name}</span>} 124 125 <input 126 value={values.email} 127 onChange={(e) => setValue('email', e.target.value)} 128 onBlur={() => setFieldTouched('email')} 129 /> 130 {touched.email && errors.email && <span>{errors.email}</span>} 131 132 <button type="submit">Submit</button> 133 </form> 134 ); 135}

Real-Time Validation#

1// Debounced async validation 2import { useState, useEffect } from 'react'; 3import { useDebounce } from './hooks/useDebounce'; 4 5function UsernameInput() { 6 const [username, setUsername] = useState(''); 7 const [isChecking, setIsChecking] = useState(false); 8 const [isAvailable, setIsAvailable] = useState<boolean | null>(null); 9 10 const debouncedUsername = useDebounce(username, 500); 11 12 useEffect(() => { 13 if (debouncedUsername.length < 3) { 14 setIsAvailable(null); 15 return; 16 } 17 18 async function checkAvailability() { 19 setIsChecking(true); 20 try { 21 const available = await checkUsername(debouncedUsername); 22 setIsAvailable(available); 23 } finally { 24 setIsChecking(false); 25 } 26 } 27 28 checkAvailability(); 29 }, [debouncedUsername]); 30 31 return ( 32 <div> 33 <input 34 value={username} 35 onChange={(e) => setUsername(e.target.value)} 36 placeholder="Username" 37 /> 38 39 {isChecking && <span>Checking...</span>} 40 41 {!isChecking && isAvailable === true && ( 42 <span className="success">Username available</span> 43 )} 44 45 {!isChecking && isAvailable === false && ( 46 <span className="error">Username taken</span> 47 )} 48 </div> 49 ); 50}

Field-Level Components#

1interface TextFieldProps { 2 name: string; 3 label: string; 4 value: string; 5 onChange: (value: string) => void; 6 onBlur: () => void; 7 error?: string; 8 touched?: boolean; 9 required?: boolean; 10 type?: 'text' | 'email' | 'password'; 11} 12 13function TextField({ 14 name, 15 label, 16 value, 17 onChange, 18 onBlur, 19 error, 20 touched, 21 required, 22 type = 'text', 23}: TextFieldProps) { 24 const showError = touched && error; 25 26 return ( 27 <div className="field"> 28 <label htmlFor={name}> 29 {label} 30 {required && <span className="required">*</span>} 31 </label> 32 33 <input 34 id={name} 35 name={name} 36 type={type} 37 value={value} 38 onChange={(e) => onChange(e.target.value)} 39 onBlur={onBlur} 40 aria-invalid={showError ? 'true' : 'false'} 41 aria-describedby={showError ? `${name}-error` : undefined} 42 className={showError ? 'error' : ''} 43 /> 44 45 {showError && ( 46 <span id={`${name}-error`} className="error-message" role="alert"> 47 {error} 48 </span> 49 )} 50 </div> 51 ); 52} 53 54// Usage 55<TextField 56 name="email" 57 label="Email Address" 58 value={values.email} 59 onChange={(value) => setValue('email', value)} 60 onBlur={() => setFieldTouched('email')} 61 error={errors.email} 62 touched={touched.email} 63 required 64 type="email" 65/>

Server-Side Validation#

1// Handle server validation errors 2async function handleSubmit(data: FormData) { 3 try { 4 await api.createUser(data); 5 router.push('/success'); 6 } catch (error) { 7 if (error.response?.status === 400) { 8 // Map server errors to form fields 9 const serverErrors = error.response.data.errors; 10 Object.keys(serverErrors).forEach((field) => { 11 setError(field, { 12 type: 'server', 13 message: serverErrors[field], 14 }); 15 }); 16 } else { 17 // General error 18 setError('root', { 19 message: 'Something went wrong. Please try again.', 20 }); 21 } 22 } 23} 24 25// Display root errors 26{errors.root && ( 27 <div className="alert alert-error"> 28 {errors.root.message} 29 </div> 30)}

Accessibility#

1function AccessibleForm() { 2 return ( 3 <form onSubmit={handleSubmit} noValidate aria-label="Contact form"> 4 <div className="field"> 5 <label htmlFor="email"> 6 Email Address 7 <span className="required" aria-label="required">*</span> 8 </label> 9 10 <input 11 id="email" 12 type="email" 13 aria-required="true" 14 aria-invalid={!!errors.email} 15 aria-describedby={errors.email ? 'email-error' : 'email-hint'} 16 /> 17 18 <span id="email-hint" className="hint"> 19 We'll never share your email 20 </span> 21 22 {errors.email && ( 23 <span id="email-error" className="error" role="alert"> 24 {errors.email} 25 </span> 26 )} 27 </div> 28 29 {/* Announce form errors to screen readers */} 30 <div aria-live="polite" className="sr-only"> 31 {Object.keys(errors).length > 0 && ( 32 <span>Form has {Object.keys(errors).length} errors</span> 33 )} 34 </div> 35 36 <button type="submit">Submit</button> 37 </form> 38 ); 39}

Best Practices#

UX: ✓ Validate on blur, not on change ✓ Show errors after interaction ✓ Provide clear error messages ✓ Don't disable submit button Performance: ✓ Debounce async validation ✓ Validate client-side first ✓ Use schema validation (Zod) ✓ Minimize re-renders Accessibility: ✓ Use proper labels ✓ Add aria attributes ✓ Announce errors ✓ Support keyboard navigation

Conclusion#

Good form validation combines immediate feedback with clear error messages. Use established libraries like React Hook Form with Zod for complex forms, implement real-time validation for critical fields, and always consider accessibility. The goal is helping users succeed, not catching mistakes.

Share this article

Help spread the word about Bootspring