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.