Back to Blog
ReactFormsValidationUX

React Form Validation Patterns

Build robust form validation in React. From custom hooks to schema validation to real-time feedback.

B
Bootspring Team
Engineering
February 1, 2021
9 min read

Effective form validation improves UX and data quality. Here's how to implement it.

Basic Controlled Form#

1import { useState, FormEvent } from 'react'; 2 3interface FormData { 4 email: string; 5 password: string; 6} 7 8interface FormErrors { 9 email?: string; 10 password?: string; 11} 12 13function LoginForm() { 14 const [formData, setFormData] = useState<FormData>({ 15 email: '', 16 password: '', 17 }); 18 const [errors, setErrors] = useState<FormErrors>({}); 19 const [isSubmitting, setIsSubmitting] = useState(false); 20 21 const validate = (data: FormData): FormErrors => { 22 const errors: FormErrors = {}; 23 24 if (!data.email) { 25 errors.email = 'Email is required'; 26 } else if (!/\S+@\S+\.\S+/.test(data.email)) { 27 errors.email = 'Email is invalid'; 28 } 29 30 if (!data.password) { 31 errors.password = 'Password is required'; 32 } else if (data.password.length < 8) { 33 errors.password = 'Password must be at least 8 characters'; 34 } 35 36 return errors; 37 }; 38 39 const handleSubmit = async (e: FormEvent) => { 40 e.preventDefault(); 41 const validationErrors = validate(formData); 42 43 if (Object.keys(validationErrors).length > 0) { 44 setErrors(validationErrors); 45 return; 46 } 47 48 setIsSubmitting(true); 49 try { 50 await submitForm(formData); 51 } finally { 52 setIsSubmitting(false); 53 } 54 }; 55 56 const handleChange = (field: keyof FormData) => ( 57 e: React.ChangeEvent<HTMLInputElement> 58 ) => { 59 setFormData(prev => ({ ...prev, [field]: e.target.value })); 60 // Clear error on change 61 if (errors[field]) { 62 setErrors(prev => ({ ...prev, [field]: undefined })); 63 } 64 }; 65 66 return ( 67 <form onSubmit={handleSubmit}> 68 <div> 69 <input 70 type="email" 71 value={formData.email} 72 onChange={handleChange('email')} 73 aria-invalid={!!errors.email} 74 /> 75 {errors.email && <span className="error">{errors.email}</span>} 76 </div> 77 78 <div> 79 <input 80 type="password" 81 value={formData.password} 82 onChange={handleChange('password')} 83 aria-invalid={!!errors.password} 84 /> 85 {errors.password && <span className="error">{errors.password}</span>} 86 </div> 87 88 <button type="submit" disabled={isSubmitting}> 89 {isSubmitting ? 'Submitting...' : 'Login'} 90 </button> 91 </form> 92 ); 93}

Custom useForm Hook#

1import { useState, useCallback, ChangeEvent, FormEvent } from 'react'; 2 3interface UseFormOptions<T> { 4 initialValues: T; 5 validate: (values: T) => Partial<Record<keyof T, string>>; 6 onSubmit: (values: T) => Promise<void>; 7} 8 9function useForm<T extends Record<string, any>>({ 10 initialValues, 11 validate, 12 onSubmit, 13}: UseFormOptions<T>) { 14 const [values, setValues] = useState<T>(initialValues); 15 const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({}); 16 const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({}); 17 const [isSubmitting, setIsSubmitting] = useState(false); 18 19 const handleChange = useCallback(( 20 e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement> 21 ) => { 22 const { name, value, type } = e.target; 23 const newValue = type === 'checkbox' 24 ? (e.target as HTMLInputElement).checked 25 : value; 26 27 setValues(prev => ({ ...prev, [name]: newValue })); 28 }, []); 29 30 const handleBlur = useCallback(( 31 e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement> 32 ) => { 33 const { name } = e.target; 34 setTouched(prev => ({ ...prev, [name]: true })); 35 36 // Validate on blur 37 const validationErrors = validate(values); 38 setErrors(validationErrors); 39 }, [values, validate]); 40 41 const handleSubmit = useCallback(async (e: FormEvent) => { 42 e.preventDefault(); 43 44 // Touch all fields 45 const allTouched = Object.keys(values).reduce( 46 (acc, key) => ({ ...acc, [key]: true }), 47 {} as Record<keyof T, boolean> 48 ); 49 setTouched(allTouched); 50 51 // Validate 52 const validationErrors = validate(values); 53 setErrors(validationErrors); 54 55 if (Object.keys(validationErrors).length > 0) { 56 return; 57 } 58 59 setIsSubmitting(true); 60 try { 61 await onSubmit(values); 62 } finally { 63 setIsSubmitting(false); 64 } 65 }, [values, validate, onSubmit]); 66 67 const reset = useCallback(() => { 68 setValues(initialValues); 69 setErrors({}); 70 setTouched({}); 71 setIsSubmitting(false); 72 }, [initialValues]); 73 74 const setFieldValue = useCallback((name: keyof T, value: any) => { 75 setValues(prev => ({ ...prev, [name]: value })); 76 }, []); 77 78 const setFieldError = useCallback((name: keyof T, error: string) => { 79 setErrors(prev => ({ ...prev, [name]: error })); 80 }, []); 81 82 return { 83 values, 84 errors, 85 touched, 86 isSubmitting, 87 handleChange, 88 handleBlur, 89 handleSubmit, 90 reset, 91 setFieldValue, 92 setFieldError, 93 getFieldProps: (name: keyof T) => ({ 94 name, 95 value: values[name], 96 onChange: handleChange, 97 onBlur: handleBlur, 98 }), 99 getFieldMeta: (name: keyof T) => ({ 100 error: errors[name], 101 touched: touched[name], 102 hasError: touched[name] && !!errors[name], 103 }), 104 }; 105} 106 107// Usage 108function RegistrationForm() { 109 const form = useForm({ 110 initialValues: { 111 username: '', 112 email: '', 113 password: '', 114 confirmPassword: '', 115 }, 116 validate: (values) => { 117 const errors: Record<string, string> = {}; 118 119 if (!values.username) errors.username = 'Required'; 120 if (!values.email) errors.email = 'Required'; 121 if (!values.password) errors.password = 'Required'; 122 if (values.password !== values.confirmPassword) { 123 errors.confirmPassword = 'Passwords must match'; 124 } 125 126 return errors; 127 }, 128 onSubmit: async (values) => { 129 await registerUser(values); 130 }, 131 }); 132 133 return ( 134 <form onSubmit={form.handleSubmit}> 135 <Field label="Username" {...form.getFieldProps('username')} {...form.getFieldMeta('username')} /> 136 <Field label="Email" {...form.getFieldProps('email')} {...form.getFieldMeta('email')} /> 137 <Field label="Password" type="password" {...form.getFieldProps('password')} {...form.getFieldMeta('password')} /> 138 <Field label="Confirm Password" type="password" {...form.getFieldProps('confirmPassword')} {...form.getFieldMeta('confirmPassword')} /> 139 <button type="submit" disabled={form.isSubmitting}>Register</button> 140 </form> 141 ); 142}

Schema Validation with Zod#

1import { z } from 'zod'; 2 3// Define schema 4const userSchema = z.object({ 5 username: z 6 .string() 7 .min(3, 'Username must be at least 3 characters') 8 .max(20, 'Username must be at most 20 characters') 9 .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'), 10 11 email: z 12 .string() 13 .email('Invalid email address'), 14 15 password: z 16 .string() 17 .min(8, 'Password must be at least 8 characters') 18 .regex(/[A-Z]/, 'Password must contain an uppercase letter') 19 .regex(/[a-z]/, 'Password must contain a lowercase letter') 20 .regex(/[0-9]/, 'Password must contain a number'), 21 22 age: z 23 .number() 24 .min(18, 'Must be at least 18') 25 .max(120, 'Invalid age'), 26 27 website: z 28 .string() 29 .url('Invalid URL') 30 .optional(), 31}); 32 33type UserFormData = z.infer<typeof userSchema>; 34 35// Hook with Zod validation 36function useZodForm<T extends z.ZodType>(schema: T) { 37 type FormData = z.infer<T>; 38 39 const validate = (values: FormData): Partial<Record<keyof FormData, string>> => { 40 const result = schema.safeParse(values); 41 42 if (result.success) { 43 return {}; 44 } 45 46 const errors: Record<string, string> = {}; 47 result.error.errors.forEach((err) => { 48 const path = err.path.join('.'); 49 if (!errors[path]) { 50 errors[path] = err.message; 51 } 52 }); 53 54 return errors; 55 }; 56 57 return { validate }; 58} 59 60// Usage 61function ZodForm() { 62 const { validate } = useZodForm(userSchema); 63 64 const form = useForm({ 65 initialValues: { 66 username: '', 67 email: '', 68 password: '', 69 age: 0, 70 website: '', 71 }, 72 validate, 73 onSubmit: async (values) => { 74 // Values are validated and typed 75 console.log(values); 76 }, 77 }); 78 79 return <form onSubmit={form.handleSubmit}>{/* ... */}</form>; 80}

Real-Time Validation#

1import { useState, useEffect, useCallback } from 'react'; 2import { useDebouncedCallback } from 'use-debounce'; 3 4function useFieldValidation( 5 value: string, 6 validate: (value: string) => string | null, 7 debounceMs = 300 8) { 9 const [error, setError] = useState<string | null>(null); 10 const [isValidating, setIsValidating] = useState(false); 11 12 const debouncedValidate = useDebouncedCallback( 13 async (val: string) => { 14 setIsValidating(true); 15 const result = validate(val); 16 setError(result); 17 setIsValidating(false); 18 }, 19 debounceMs 20 ); 21 22 useEffect(() => { 23 if (value) { 24 debouncedValidate(value); 25 } else { 26 setError(null); 27 } 28 }, [value, debouncedValidate]); 29 30 return { error, isValidating }; 31} 32 33// Async validation (e.g., check username availability) 34function useAsyncValidation<T>( 35 value: T, 36 validator: (value: T) => Promise<string | null>, 37 debounceMs = 500 38) { 39 const [error, setError] = useState<string | null>(null); 40 const [isValidating, setIsValidating] = useState(false); 41 42 const validate = useCallback(async () => { 43 setIsValidating(true); 44 try { 45 const result = await validator(value); 46 setError(result); 47 } catch { 48 setError('Validation failed'); 49 } finally { 50 setIsValidating(false); 51 } 52 }, [value, validator]); 53 54 useEffect(() => { 55 const timeoutId = setTimeout(validate, debounceMs); 56 return () => clearTimeout(timeoutId); 57 }, [validate, debounceMs]); 58 59 return { error, isValidating }; 60} 61 62// Usage 63function UsernameField() { 64 const [username, setUsername] = useState(''); 65 66 const { error, isValidating } = useAsyncValidation( 67 username, 68 async (value) => { 69 if (!value) return null; 70 const available = await checkUsernameAvailable(value); 71 return available ? null : 'Username is taken'; 72 } 73 ); 74 75 return ( 76 <div> 77 <input 78 value={username} 79 onChange={(e) => setUsername(e.target.value)} 80 /> 81 {isValidating && <span>Checking...</span>} 82 {error && <span className="error">{error}</span>} 83 </div> 84 ); 85}

Field Components#

1interface FieldProps { 2 label: string; 3 name: string; 4 type?: string; 5 value: string; 6 error?: string; 7 touched?: boolean; 8 hasError?: boolean; 9 onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; 10 onBlur: (e: React.ChangeEvent<HTMLInputElement>) => void; 11} 12 13function Field({ 14 label, 15 name, 16 type = 'text', 17 value, 18 error, 19 touched, 20 hasError, 21 onChange, 22 onBlur, 23}: FieldProps) { 24 const id = `field-${name}`; 25 const errorId = `${id}-error`; 26 27 return ( 28 <div className="field"> 29 <label htmlFor={id}>{label}</label> 30 <input 31 id={id} 32 name={name} 33 type={type} 34 value={value} 35 onChange={onChange} 36 onBlur={onBlur} 37 aria-invalid={hasError} 38 aria-describedby={hasError ? errorId : undefined} 39 className={hasError ? 'input-error' : ''} 40 /> 41 {hasError && ( 42 <span id={errorId} className="error" role="alert"> 43 {error} 44 </span> 45 )} 46 </div> 47 ); 48} 49 50// Select field 51interface SelectFieldProps extends Omit<FieldProps, 'type' | 'onChange' | 'onBlur'> { 52 options: { value: string; label: string }[]; 53 onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void; 54 onBlur: (e: React.ChangeEvent<HTMLSelectElement>) => void; 55} 56 57function SelectField({ 58 label, 59 name, 60 value, 61 options, 62 error, 63 hasError, 64 onChange, 65 onBlur, 66}: SelectFieldProps) { 67 return ( 68 <div className="field"> 69 <label htmlFor={name}>{label}</label> 70 <select 71 id={name} 72 name={name} 73 value={value} 74 onChange={onChange} 75 onBlur={onBlur} 76 aria-invalid={hasError} 77 > 78 <option value="">Select...</option> 79 {options.map((opt) => ( 80 <option key={opt.value} value={opt.value}> 81 {opt.label} 82 </option> 83 ))} 84 </select> 85 {hasError && <span className="error">{error}</span>} 86 </div> 87 ); 88}

Multi-Step Form#

1interface Step { 2 id: string; 3 component: React.ComponentType<StepProps>; 4 validate: (values: any) => Record<string, string>; 5} 6 7interface StepProps { 8 values: Record<string, any>; 9 errors: Record<string, string>; 10 onChange: (name: string, value: any) => void; 11} 12 13function MultiStepForm({ steps, onSubmit }: { 14 steps: Step[]; 15 onSubmit: (values: any) => Promise<void>; 16}) { 17 const [currentStep, setCurrentStep] = useState(0); 18 const [values, setValues] = useState<Record<string, any>>({}); 19 const [errors, setErrors] = useState<Record<string, string>>({}); 20 const [isSubmitting, setIsSubmitting] = useState(false); 21 22 const step = steps[currentStep]; 23 const isFirstStep = currentStep === 0; 24 const isLastStep = currentStep === steps.length - 1; 25 26 const handleChange = (name: string, value: any) => { 27 setValues((prev) => ({ ...prev, [name]: value })); 28 setErrors((prev) => ({ ...prev, [name]: '' })); 29 }; 30 31 const validateStep = () => { 32 const stepErrors = step.validate(values); 33 setErrors(stepErrors); 34 return Object.keys(stepErrors).length === 0; 35 }; 36 37 const handleNext = () => { 38 if (validateStep()) { 39 setCurrentStep((prev) => prev + 1); 40 } 41 }; 42 43 const handlePrev = () => { 44 setCurrentStep((prev) => prev - 1); 45 }; 46 47 const handleSubmit = async (e: FormEvent) => { 48 e.preventDefault(); 49 50 if (!validateStep()) return; 51 52 setIsSubmitting(true); 53 try { 54 await onSubmit(values); 55 } finally { 56 setIsSubmitting(false); 57 } 58 }; 59 60 const StepComponent = step.component; 61 62 return ( 63 <form onSubmit={handleSubmit}> 64 <div className="progress"> 65 {steps.map((s, i) => ( 66 <div 67 key={s.id} 68 className={`step ${i === currentStep ? 'active' : ''} ${ 69 i < currentStep ? 'completed' : '' 70 }`} 71 > 72 {i + 1} 73 </div> 74 ))} 75 </div> 76 77 <StepComponent 78 values={values} 79 errors={errors} 80 onChange={handleChange} 81 /> 82 83 <div className="buttons"> 84 {!isFirstStep && ( 85 <button type="button" onClick={handlePrev}> 86 Previous 87 </button> 88 )} 89 {!isLastStep && ( 90 <button type="button" onClick={handleNext}> 91 Next 92 </button> 93 )} 94 {isLastStep && ( 95 <button type="submit" disabled={isSubmitting}> 96 {isSubmitting ? 'Submitting...' : 'Submit'} 97 </button> 98 )} 99 </div> 100 </form> 101 ); 102}

Form Array (Dynamic Fields)#

1function DynamicForm() { 2 const [items, setItems] = useState<{ id: string; value: string }[]>([ 3 { id: '1', value: '' }, 4 ]); 5 6 const addItem = () => { 7 setItems((prev) => [ 8 ...prev, 9 { id: Date.now().toString(), value: '' }, 10 ]); 11 }; 12 13 const removeItem = (id: string) => { 14 setItems((prev) => prev.filter((item) => item.id !== id)); 15 }; 16 17 const updateItem = (id: string, value: string) => { 18 setItems((prev) => 19 prev.map((item) => 20 item.id === id ? { ...item, value } : item 21 ) 22 ); 23 }; 24 25 return ( 26 <form> 27 {items.map((item, index) => ( 28 <div key={item.id} className="field-row"> 29 <input 30 value={item.value} 31 onChange={(e) => updateItem(item.id, e.target.value)} 32 placeholder={`Item ${index + 1}`} 33 /> 34 {items.length > 1 && ( 35 <button type="button" onClick={() => removeItem(item.id)}> 36 Remove 37 </button> 38 )} 39 </div> 40 ))} 41 <button type="button" onClick={addItem}> 42 Add Item 43 </button> 44 </form> 45 ); 46}

Best Practices#

Validation Timing: ✓ Validate on blur for first interaction ✓ Validate on change after first error ✓ Debounce async validations ✓ Show errors only after touch UX: ✓ Clear errors when user starts typing ✓ Use aria attributes for accessibility ✓ Show loading states for async validation ✓ Disable submit during submission Performance: ✓ Memoize validation functions ✓ Use controlled inputs for complex logic ✓ Debounce real-time validation ✓ Avoid re-renders with proper state structure Security: ✓ Validate on server too ✓ Sanitize inputs ✓ Protect against XSS ✓ Rate limit submissions

Conclusion#

Form validation improves data quality and user experience. Use custom hooks for reusable logic, schema libraries for complex validation, and real-time feedback for better UX. Always validate on the server as well.

Share this article

Help spread the word about Bootspring