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.