Next.js course Β· Module 6: Forms and Server Actions
Form Error Handling and Messages
In this lesson8
Effective form error handling is a key element of building user-friendly web applications. Well-designed error messages not only inform the user about problems, but also indicate how to resolve them. In this module, we'll discuss best practices for error handling in React and Next.js forms, from designing user-friendly messages to advanced validation techniques.
Types of Errors in Forms
Before diving into implementation details, it's worth understanding the different types of errors that can occur in forms:
- Validation errors - related to incorrect data entered by the user (e.g. invalid email format)
- Server errors - problems while processing data on the server side (e.g. database error)
- Network errors - connection problems (e.g. timeout, offline)
- Business errors - data is technically correct but violates business rules (e.g. duplicate email address)
- Accessibility errors - the form is difficult or impossible to use for people with disabilities
Designing User-friendly Error Messages
Well-designed error messages should meet the following criteria:
1. Be Clear and Specific
Bad: "Validation error" Good: "Password must contain at least 8 characters, including a digit and an uppercase letter"
2. Use User-friendly Language
Bad: "An error occurred parsing JSON: unexpected token at position 42" Good: "A technical problem occurred. Please try again or contact us."
3. Suggest a Solution
Bad: "Invalid phone number format" Good: "Enter a phone number in the format 123-456-789"
4. Be Visible in the Right Place
Error messages should be displayed close to the problematic field, not only at the top of the form.
5. Have Appropriate Contrast and Accessibility
Error messages should be clearly visible and accessible to all users, including those using screen readers.
Implementing Error Handling in React Forms
Let's move on to practical examples of implementing error handling across different form approaches.
Native React Forms
In native React forms, we can handle errors using state:
1import { useState, FormEvent, ChangeEvent } from 'react';
2
3type FormErrors = {
4 name?: string;
5 email?: string;
6 password?: string;
7 generalError?: string;
8};
9
10export default function SignupForm() {
11 const [formData, setFormData] = useState({
12 name: '',
13 email: '',
14 password: ''
15 });
16
17 const [errors, setErrors] = useState<FormErrors>({});
18 const [isSubmitting, setIsSubmitting] = useState(false);
19 const [submitSuccess, setSubmitSuccess] = useState(false);
20
21 const validateForm = (): boolean => {
22 const newErrors: FormErrors = {};
23 let isValid = true;
24
25 // Validation imienia
26 if (!formData.name.trim()) {
27 newErrors.name = 'Name is required';
28 isValid = false;
29 }
30
31 // Validation email
32 if (!formData.email.trim()) {
33 newErrors.email = 'Email is required';
34 isValid = false;
35 } else if (!/^\S+@\S+\.\S+$/.test(formData.email)) {
36 newErrors.email = 'Please provide a valid email address';
37 isValid = false;
38 }
39
40 // Password validation
41 if (!formData.password) {
42 newErrors.password = 'Password is required';
43 isValid = false;
44 } else if (formData.password.length < 8) {
45 newErrors.password = 'Password must be at least 8 characters';
46 isValid = false;
47 } else if (!/[A-Z]/.test(formData.password)) {
48 newErrors.password = 'Password must contain at least one uppercase letter';
49 isValid = false;
50 } else if (!/[0-9]/.test(formData.password)) {
51 newErrors.password = 'Password must contain at least one digit';
52 isValid = false;
53 }
54
55 setErrors(newErrors);
56 return isValid;
57 };
58
59 const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
60 const { name, value } = e.target;
61
62 // Update form data
63 setFormData(prev => ({
64 ...prev,
65 [name]: value
66 }));
67
68 // Remove error for edited field
69 if (errors[name as keyof FormErrors]) {
70 setErrors(prev => {
71 const newErrors = { ...prev };
72 delete newErrors[name as keyof FormErrors];
73 return newErrors;
74 });
75 }
76 };
77
78 const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
79 e.preventDefault();
80
81 // Reset state before submission
82 setErrors({});
83 setSubmitSuccess(false);
84
85 // Validate form
86 if (!validateForm()) {
87 return;
88 }
89
90 setIsSubmitting(true);
91
92 try {
93 // Simulate API submission with random error
94 await new Promise(resolve => setTimeout(resolve, 1000));
95
96 // Simulate random server error (25% chance)
97 if (Math.random() < 0.25) {
98 throw new Error('An error occurred while processing the form');
99 }
100
101 // Simulate business error - already existing email (25% chance)
102 if (Math.random() < 0.25) {
103 setErrors({
104 email: 'This email address is already registered'
105 });
106 return;
107 }
108
109 // Success
110 setSubmitSuccess(true);
111 setFormData({ name: '', email: '', password: '' });
112 console.log('Form sent successfully:', formData);
113 } catch (error) {
114 // Handle server error
115 setErrors({
116 generalError: 'A problem occurred while processing the form. Please try again later.'
117 });
118 console.error('Form submission error:', error);
119 } finally {
120 setIsSubmitting(false);
121 }
122 };
123
124 return (
125 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
126 <h1 className="text-2xl font-bold mb-6">Registration</h1>
127
128 {submitSuccess && (
129 <div className="mb-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded">
130 Registration was successful! Check your email to activate your account.
131 </div>
132 )}
133
134 {errors.generalError && (
135 <div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded" role="alert">
136 <p className="font-bold">An error occurred</p>
137 <p>{errors.generalError}</p>
138 </div>
139 )}
140
141 <form onSubmit={handleSubmit} className="space-y-4" noValidate>
142 <div>
143 <label htmlFor="name" className="block text-sm font-medium text-gray-700">
144 Full name
145 </label>
146 <input
147 type="text"
148 id="name"
149 name="name"
150 value={formData.name}
151 onChange={handleChange}
152 aria-invalid={Boolean(errors.name)}
153 aria-describedby={errors.name ? 'name-error' : undefined}
154 className={`mt-1 block w-full rounded-md shadow-sm ${errors.name ? 'border-red-500' : 'border-gray-300'}`}
155 />
156 {errors.name && (
157 <p className="mt-1 text-sm text-red-600" id="name-error" role="alert">
158 {errors.name}
159 </p>
160 )}
161 </div>
162
163 <div>
164 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
165 Email
166 </label>
167 <input
168 type="email"
169 id="email"
170 name="email"
171 value={formData.email}
172 onChange={handleChange}
173 aria-invalid={Boolean(errors.email)}
174 aria-describedby={errors.email ? 'email-error' : undefined}
175 className={`mt-1 block w-full rounded-md shadow-sm ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
176 />
177 {errors.email && (
178 <p className="mt-1 text-sm text-red-600" id="email-error" role="alert">
179 {errors.email}
180 </p>
181 )}
182 </div>
183
184 <div>
185 <label htmlFor="password" className="block text-sm font-medium text-gray-700">
186 Password
187 </label>
188 <input
189 type="password"
190 id="password"
191 name="password"
192 value={formData.password}
193 onChange={handleChange}
194 aria-invalid={Boolean(errors.password)}
195 aria-describedby={errors.password ? 'password-error' : 'password-rules'}
196 className={`mt-1 block w-full rounded-md shadow-sm ${errors.password ? 'border-red-500' : 'border-gray-300'}`}
197 />
198 {errors.password ? (
199 <p className="mt-1 text-sm text-red-600" id="password-error" role="alert">
200 {errors.password}
201 </p>
202 ) : (
203 <p className="mt-1 text-xs text-gray-500" id="password-rules">
204 Password should be at least 8 characters, contain an uppercase letter and a digit.
205 </p>
206 )}
207 </div>
208
209 <button
210 type="submit"
211 disabled={isSubmitting}
212 className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
213 >
214 {isSubmitting ? 'Processing...' : 'Sign up'}
215 </button>
216 </form>
217 </div>
218 );
219}Key error handling elements in this example:
- Error state - a separate object storing errors for individual fields and a general error
- Accessibility attributes -
aria-invalidandaria-describedbyfor screen readers - Dynamic validation - removing errors while editing a field
- Visual cues - red border for invalid fields
- Helper messages - hints about password rules before an error occurs
Error Handling with React Hook Form
React Hook Form offers advanced error handling capabilities:
1import { useForm, SubmitHandler } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4import { useState } from 'react';
5
6// Validation schema
7const signupSchema = z.object({
8 name: z.string().min(1, { message: 'Name is required' }),
9 email: z.string().email({ message: 'Please provide a valid email address' }),
10 password: z.string()
11 .min(8, { message: 'Password must be at least 8 characters' })
12 .regex(/[A-Z]/, { message: 'Password must contain at least one uppercase letter' })
13 .regex(/[0-9]/, { message: 'Password must contain at least one digit' })
14});
15
16type SignupFormInputs = z.infer<typeof signupSchema>;
17
18export default function HookFormSignup() {
19 const [serverError, setServerError] = useState<string | null>(null);
20 const [submitSuccess, setSubmitSuccess] = useState(false);
21
22 const {
23 register,
24 handleSubmit,
25 formState: { errors, isSubmitting, touchedFields, isDirty },
26 reset
27 } = useForm<SignupFormInputs>({
28 resolver: zodResolver(signupSchema),
29 mode: 'onTouched' // Validation on blur
30 });
31
32 const onSubmit: SubmitHandler<SignupFormInputs> = async (data) => {
33 // Reset server errors and success before new attempt
34 setServerError(null);
35 setSubmitSuccess(false);
36
37 try {
38 // Simulate API submission
39 await new Promise(resolve => setTimeout(resolve, 1000));
40
41 // Simulate random server error (25% chance)
42 if (Math.random() < 0.25) {
43 throw new Error('An error occurred while processing the form');
44 }
45
46 // Simulate business error - already existing email (25% chance)
47 if (Math.random() < 0.25) {
48 throw new Error('This email address is already registered');
49 }
50
51 // Success
52 console.log('Form sent successfully:', data);
53 setSubmitSuccess(true);
54 reset(); // Clear form
55 } catch (error) {
56 if (error instanceof Error) {
57 setServerError(error.message);
58 } else {
59 setServerError('An unknown error occurred. Please try again later.');
60 }
61 console.error('Form submission error:', error);
62 }
63 };
64
65 return (
66 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
67 <h1 className="text-2xl font-bold mb-6">Registration (React Hook Form)</h1>
68
69 {submitSuccess && (
70 <div className="mb-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded">
71 Registration was successful! Check your email to activate your account.
72 </div>
73 )}
74
75 {serverError && (
76 <div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded" role="alert">
77 <p className="font-bold">An error occurred</p>
78 <p>{serverError}</p>
79 </div>
80 )}
81
82 <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
83 <div>
84 <label htmlFor="name" className="block text-sm font-medium text-gray-700">
85 Full name
86 </label>
87 <input
88 id="name"
89 type="text"
90 aria-invalid={Boolean(errors.name)}
91 aria-describedby={errors.name ? 'name-error' : undefined}
92 className={`mt-1 block w-full rounded-md shadow-sm ${errors.name ? 'border-red-500' : touchedFields.name ? 'border-green-500' : 'border-gray-300'}`}
93 {...register('name')}
94 />
95 {errors.name && (
96 <p className="mt-1 text-sm text-red-600" id="name-error" role="alert">
97 {errors.name.message}
98 </p>
99 )}
100 </div>
101
102 <div>
103 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
104 Email
105 </label>
106 <input
107 id="email"
108 type="email"
109 aria-invalid={Boolean(errors.email)}
110 aria-describedby={errors.email ? 'email-error' : undefined}
111 className={`mt-1 block w-full rounded-md shadow-sm ${errors.email ? 'border-red-500' : touchedFields.email ? 'border-green-500' : 'border-gray-300'}`}
112 {...register('email')}
113 />
114 {errors.email && (
115 <p className="mt-1 text-sm text-red-600" id="email-error" role="alert">
116 {errors.email.message}
117 </p>
118 )}
119 </div>
120
121 <div>
122 <label htmlFor="password" className="block text-sm font-medium text-gray-700">
123 Password
124 </label>
125 <input
126 id="password"
127 type="password"
128 aria-invalid={Boolean(errors.password)}
129 aria-describedby={errors.password ? 'password-error' : 'password-rules'}
130 className={`mt-1 block w-full rounded-md shadow-sm ${errors.password ? 'border-red-500' : touchedFields.password ? 'border-green-500' : 'border-gray-300'}`}
131 {...register('password')}
132 />
133 {errors.password ? (
134 <p className="mt-1 text-sm text-red-600" id="password-error" role="alert">
135 {errors.password.message}
136 </p>
137 ) : (
138 <p className="mt-1 text-xs text-gray-500" id="password-rules">
139 Password should be at least 8 characters, contain an uppercase letter and a digit.
140 </p>
141 )}
142 </div>
143
144 <button
145 type="submit"
146 disabled={isSubmitting || !isDirty}
147 className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
148 >
149 {isSubmitting ? 'Processing...' : 'Sign up'}
150 </button>
151 </form>
152 </div>
153 );
154}Key error handling elements in React Hook Form:
- Validation with Zod - readable and typed validation schemas
- Multiple form states -
isSubmitting,touchedFields,isDirty - Validation modes - configuration with
mode: 'onTouched' - Visual state indicators - green border for valid, red for invalid fields
- ARIA integration - full form accessibility
Error Handling in Server Actions (Next.js)
In forms using Server Actions in Next.js, error handling is especially important because processing happens on the server:
1// app/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6const contactSchema = z.object({
7 name: z.string().min(1, { message: 'Name is required' }),
8 email: z.string().email({ message: 'Please provide a valid email address' }),
9 subject: z.string().min(3, { message: 'Subject is too short' }),
10 message: z.string().min(10, { message: 'Message must be at least 10 characters' })
11});
12
13export type ContactFormState = {
14 errors?: {
15 name?: string[];
16 email?: string[];
17 subject?: string[];
18 message?: string[];
19 _form?: string[];
20 };
21 message?: string;
22 success?: boolean;
23};
24
25export async function submitContactForm(
26 prevState: ContactFormState,
27 formData: FormData
28): Promise<ContactFormState> {
29 // Convert FormData to object
30 const rawFormData = {
31 name: formData.get('name'),
32 email: formData.get('email'),
33 subject: formData.get('subject'),
34 message: formData.get('message')
35 };
36
37 // Validate data
38 const validationResult = contactSchema.safeParse(rawFormData);
39
40 // If validation fails
41 if (!validationResult.success) {
42 // Convert Zod errors to form format
43 return {
44 errors: validationResult.error.flatten().fieldErrors,
45 message: 'Please fix the errors in the form',
46 success: false
47 };
48 }
49
50 try {
51 // Simulate API delay
52 await new Promise(resolve => setTimeout(resolve, 1000));
53
54 // Simulate random server error (15% chance)
55 if (Math.random() < 0.15) {
56 throw new Error('Problem connecting to the server');
57 }
58
59 // Simulate spam error (10% chance)
60 if (Math.random() < 0.1) {
61 return {
62 errors: {
63 _form: ['Possible spam detected. Please try again later.']
64 },
65 success: false
66 };
67 }
68
69 // Simulate form processing
70 console.log('Processing contact form:', validationResult.data);
71
72 return {
73 message: 'Message was sent successfully. We will respond as soon as possible.',
74 success: true
75 };
76 } catch (error) {
77 // Handle server errors
78 return {
79 errors: {
80 _form: [
81 'An error occurred while processing the form. ' +
82 'Please try again later or contact us directly.'
83 ]
84 },
85 success: false
86 };
87 }
88}And the component using this action:
1// app/contact/page.tsx
2'use client';
3
4import { useRef, useEffect } from 'react';
5import { useFormState, useFormStatus } from 'react-dom';
6import { submitContactForm, type ContactFormState } from '../actions';
7
8// Submit button component with loading state handling
9function SubmitButton() {
10 const { pending } = useFormStatus();
11
12 return (
13 <button
14 type="submit"
15 disabled={pending}
16 aria-disabled={pending}
17 className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
18 >
19 {pending ? 'Sending...' : 'Send message'}
20 </button>
21 );
22}
23
24export default function ContactForm() {
25 const formRef = useRef<HTMLFormElement>(null);
26
27 // Initialize form state
28 const initialState: ContactFormState = { errors: {} };
29
30 // Connect with Server Action
31 const [state, formAction] = useFormState(submitContactForm, initialState);
32
33 // Reset form after successful submission
34 useEffect(() => {
35 if (state.success && formRef.current) {
36 formRef.current.reset();
37
38 // Focus on success message for screen readers
39 const successMessage = document.getElementById('success-message');
40 if (successMessage) {
41 successMessage.focus();
42 }
43 }
44 }, [state.success]);
45
46 return (
47 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
48 <h1 className="text-2xl font-bold mb-6">Contact us</h1>
49
50 {state.success && (
51 <div
52 id="success-message"
53 tabIndex={-1}
54 className="mb-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded"
55 role="alert"
56 >
57 {state.message}
58 </div>
59 )}
60
61 {state.errors?._form && (
62 <div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded" role="alert">
63 <ul className="list-disc pl-5">
64 {state.errors._form.map((error, index) => (
65 <li key={index}>{error}</li>
66 ))}
67 </ul>
68 </div>
69 )}
70
71 <form ref={formRef} action={formAction} className="space-y-4" noValidate>
72 <div>
73 <label htmlFor="name" className="block text-sm font-medium text-gray-700">
74 Full name
75 </label>
76 <input
77 type="text"
78 id="name"
79 name="name"
80 aria-invalid={Boolean(state.errors?.name)}
81 aria-describedby={state.errors?.name ? 'name-error' : undefined}
82 className={`mt-1 block w-full rounded-md shadow-sm ${state.errors?.name ? 'border-red-500' : 'border-gray-300'}`}
83 />
84 {state.errors?.name && (
85 <p className="mt-1 text-sm text-red-600" id="name-error" role="alert">
86 {state.errors.name[0]}
87 </p>
88 )}
89 </div>
90
91 <div>
92 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
93 Email
94 </label>
95 <input
96 type="email"
97 id="email"
98 name="email"
99 aria-invalid={Boolean(state.errors?.email)}
100 aria-describedby={state.errors?.email ? 'email-error' : undefined}
101 className={`mt-1 block w-full rounded-md shadow-sm ${state.errors?.email ? 'border-red-500' : 'border-gray-300'}`}
102 />
103 {state.errors?.email && (
104 <p className="mt-1 text-sm text-red-600" id="email-error" role="alert">
105 {state.errors.email[0]}
106 </p>
107 )}
108 </div>
109
110 <div>
111 <label htmlFor="subject" className="block text-sm font-medium text-gray-700">
112 Subject
113 </label>
114 <input
115 type="text"
116 id="subject"
117 name="subject"
118 aria-invalid={Boolean(state.errors?.subject)}
119 aria-describedby={state.errors?.subject ? 'subject-error' : undefined}
120 className={`mt-1 block w-full rounded-md shadow-sm ${state.errors?.subject ? 'border-red-500' : 'border-gray-300'}`}
121 />
122 {state.errors?.subject && (
123 <p className="mt-1 text-sm text-red-600" id="subject-error" role="alert">
124 {state.errors.subject[0]}
125 </p>
126 )}
127 </div>
128
129 <div>
130 <label htmlFor="message" className="block text-sm font-medium text-gray-700">
131 Message
132 </label>
133 <textarea
134 id="message"
135 name="message"
136 rows={4}
137 aria-invalid={Boolean(state.errors?.message)}
138 aria-describedby={state.errors?.message ? 'message-error' : undefined}
139 className={`mt-1 block w-full rounded-md shadow-sm ${state.errors?.message ? 'border-red-500' : 'border-gray-300'}`}
140 />
141 {state.errors?.message && (
142 <p className="mt-1 text-sm text-red-600" id="message-error" role="alert">
143 {state.errors.message[0]}
144 </p>
145 )}
146 </div>
147
148 <SubmitButton />
149 </form>
150 </div>
151 );
152}Key error handling elements with Server Actions:
- Error structure - a special format with field errors and a general form error
- useFormState and useFormStatus - hooks to track form state and submission process
- Focus after success - moving focus to the success message for screen reader users
- Response data structure - consistent format for server responses
- Multiple error types - different error scenarios for different causes
Advanced error handling patterns
Form error contexts
For more complex forms, consider creating an error context:
1// contexts/FormErrorContext.tsx
2import { createContext, useContext, ReactNode, useState } from 'react';
3
4type ErrorsType = Record<string, string[]>;
5
6interface FormErrorContextType {
7 errors: ErrorsType;
8 setErrors: (errors: ErrorsType) => void;
9 clearErrors: () => void;
10 clearFieldError: (fieldName: string) => void;
11 hasErrors: boolean;
12 getFieldErrors: (fieldName: string) => string[] | undefined;
13}
14
15const FormErrorContext = createContext<FormErrorContextType | null>(null);
16
17export function FormErrorProvider({ children }: { children: ReactNode }) {
18 const [errors, setErrors] = useState<ErrorsType>({});
19
20 const clearErrors = () => setErrors({});
21
22 const clearFieldError = (fieldName: string) => {
23 setErrors(prev => {
24 const newErrors = { ...prev };
25 delete newErrors[fieldName];
26 return newErrors;
27 });
28 };
29
30 const getFieldErrors = (fieldName: string) => errors[fieldName];
31
32 const hasErrors = Object.keys(errors).length > 0;
33
34 return (
35 <FormErrorContext.Provider value={{
36 errors,
37 setErrors,
38 clearErrors,
39 clearFieldError,
40 hasErrors,
41 getFieldErrors
42 }}>
43 {children}
44 </FormErrorContext.Provider>
45 );
46}
47
48export function useFormErrors() {
49 const context = useContext(FormErrorContext);
50 if (!context) {
51 throw new Error('useFormErrors must be used within a FormErrorProvider');
52 }
53 return context;
54}Form error component (reusable)
1// components/FormError.tsx
2import { useId } from 'react';
3
4interface FormErrorProps {
5 errors?: string[];
6 id?: string;
7}
8
9export function FormError({ errors, id: propId }: FormErrorProps) {
10 const generatedId = useId();
11 const id = propId || generatedId;
12
13 if (!errors || errors.length === 0) {
14 return null;
15 }
16
17 return (
18 <div className="mt-1" id={id} role="alert">
19 {errors.map((error, index) => (
20 <p key={index} className="text-sm text-red-600">
21 {error}
22 </p>
23 ))}
24 </div>
25 );
26}Form field component (reusable)
1// components/FormField.tsx
2import { InputHTMLAttributes, ReactNode, useId } from 'react';
3import { FormError } from './FormError';
4import { useFormErrors } from '../contexts/FormErrorContext';
5
6interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
7 label: string;
8 name: string;
9 errors?: string[];
10 children?: ReactNode;
11 helpText?: string;
12}
13
14export function FormField({
15 label,
16 name,
17 errors: propErrors,
18 children,
19 helpText,
20 ...rest
21}: FormFieldProps) {
22 const fieldId = useId();
23 const errorId = `${fieldId}-error`;
24 const helpId = `${fieldId}-help`;
25
26 // We can use the errors context if errors are not passed as a prop
27 const { getFieldErrors } = useFormErrors();
28 const errors = propErrors || getFieldErrors(name);
29
30 const hasError = errors && errors.length > 0;
31
32 // Determine which element describes this field (error or helper text)
33 const ariaDescribedBy = hasError ? errorId : helpText ? helpId : undefined;
34
35 return (
36 <div>
37 <label htmlFor={fieldId} className="block text-sm font-medium text-gray-700">
38 {label}
39 </label>
40
41 <div className="mt-1">
42 {children ? (
43 // If children are passed (e.g., custom fields)
44 children
45 ) : (
46 // Render input by default
47 <input
48 id={fieldId}
49 name={name}
50 aria-invalid={hasError}
51 aria-describedby={ariaDescribedBy}
52 className={`block w-full rounded-md shadow-sm ${hasError ? 'border-red-500' : 'border-gray-300'}`}
53 {...rest}
54 />
55 )}
56 </div>
57
58 {hasError ? (
59 // Show errors
60 <FormError errors={errors} id={errorId} />
61 ) : helpText ? (
62 // Show help text if there are no errors
63 <p className="mt-1 text-xs text-gray-500" id={helpId}>
64 {helpText}
65 </p>
66 ) : null}
67 </div>
68 );
69}Example usage of reusable components
1// app/registration/page.tsx
2import { FormErrorProvider } from '../contexts/FormErrorContext';
3import { FormField } from '../components/FormField';
4
5export default function RegistrationPage() {
6 return (
7 <FormErrorProvider>
8 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
9 <h1 className="text-2xl font-bold mb-6">Registration</h1>
10
11 <form className="space-y-4" noValidate>
12 <FormField
13 label="Full name"
14 name="name"
15 type="text"
16 required
17 helpText="Enter your full name"
18 />
19
20 <FormField
21 label="Email"
22 name="email"
23 type="email"
24 required
25 helpText="This address will be used to log into the account"
26 />
27
28 <FormField
29 label="Password"
30 name="password"
31 type="password"
32 required
33 helpText="Password should be at least 8 characters, include an uppercase letter and a digit"
34 />
35
36 {/* Custom field with its own implementation */}
37 <FormField
38 label="Account category"
39 name="accountType"
40 helpText="Choose the account type that fits your needs"
41 >
42 <div className="flex space-x-4">
43 <label className="inline-flex items-center">
44 <input type="radio" name="accountType" value="personal" className="mr-2" />
45 Personal
46 </label>
47 <label className="inline-flex items-center">
48 <input type="radio" name="accountType" value="business" className="mr-2" />
49 Business
50 </label>
51 </div>
52 </FormField>
53
54 <button
55 type="submit"
56 className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700"
57 >
58 Sign up
59 </button>
60 </form>
61 </div>
62 </FormErrorProvider>
63 );
64}Form error handling strategies and their impact on UX
Different error handling strategies can significantly affect the user experience:
1. Real-time validation vs on submit
Real-time validation:
- Pros: Immediate feedback, less frustration on submit
- Cons: Can be overwhelming and annoying if it appears too early
On-submit validation:
- Pros: Less distracting, lets the user enter data first
- Cons: Can be frustrating if the user fills out a long form and then has to go back to correct errors
Best approach: Compromise - validation on blur or after the first submit attempt
2. Client-side vs server-side validation
Client-side validation:
- Pros: Fast, no network latency, saves server resources
- Cons: Can be bypassed, doesn't always reflect actual business rules
Server-side validation:
- Pros: Reliable, consistent, takes current system state into account
- Cons: Delayed feedback, higher server load
Best approach: Validation at both levels - client for UX, server for security
3. Field-level vs whole-form validation
Individual field validation:
- Pros: Precise error information, easier corrections
- Cons: Doesn't account for dependencies between fields
Whole form validation:
- Pros: Can account for dependencies between fields
- Cons: Can be overwhelming if the form has many errors
Best approach: Field validation during input and full validation on submit
Good practices for form error handling
Group related errors - place whole-form errors at the top, specific field errors next to the fields
Maintain visual consistency - use the same colors, icons, and styles for errors throughout the application
Perfect your messages - instead of "Invalid data", write exactly what's wrong and how to fix it
Preserve data on errors - don't reset the entire form after an error occurs
Prioritize errors - show critical errors first, then less important ones
Auto-scroll to errors - especially in long forms, automatically scroll to the first error
Use ARIA - use
aria-invalid,aria-describedbyattributes and role="alert" for accessibilityMark required fields - clearly indicate which fields are required, ideally before the user starts filling out the form
Provide examples - for complex formats (e.g., date, phone number) provide examples
Test with users - regularly test forms with real users to identify problems
Error handling and accessibility
Effective error handling is especially important for users with disabilities:
Use ARIA attributes
aria-invalid="true"for invalid fieldsaria-describedbylinking the field to the error messagerole="alert"for error messages that should be read immediately
Don't rely on color alone
- Add icons or other visual cues besides red color
- Ensure sufficient contrast between text and background
Use
<fieldset>and<legend>- Group related fields using
<fieldset> - Describe groups of fields using
<legend>
- Group related fields using
Correct tab order
- Make sure keyboard users can move between fields in a logical order
Error summary
- For complex forms, add an error summary at the top of the form
- Each error should contain a link to the corresponding field
Summary
Effective form error handling is much more than just informing the user about problems. It's a comprehensive approach that combines:
- Designing friendly messages - clear, specific, and helpful
- Multi-level validation - client + server, individual fields + whole form
- Appropriate timing - choosing the optimal moment to show errors
- Accessibility - ensuring all users can understand and correct errors
- Visual consistency - uniform styles and behaviors throughout the application
Remember that the goal is not only to detect errors, but above all to help the user correct them effectively and successfully complete the form. Well-designed error handling can significantly improve conversion rates and user satisfaction.
Code for this lesson: App.tsx
1import React, { useState } from 'react';
2
3// Handling form errors - messages, UX, display strategies
4
5type ValidationStrategy = 'onSubmit' | 'onBlur' | 'onChange';
6
7interface FieldState {
8 value: string;
9 error: string;
10 touched: boolean;
11}
12
13const validateField = (name: string, value: string): string => {
14 switch (name) {
15 case 'name':
16 if (!value) return 'Name is required';
17 if (value.length < 2) return 'Name must have at least 2 characters';
18 return '';
19 case 'email':
20 if (!value) return 'Email is required';
21 if (!/\S+@\S+\.\S+/.test(value)) return 'Invalid email format';
22 return '';
23 case 'password':
24 if (!value) return 'Password is required';
25 if (value.length < 8) return 'Password must have at least 8 characters';
26 if (!/[A-Z]/.test(value)) return 'Password must contain an uppercase letter';
27 if (!/[0-9]/.test(value)) return 'Password must contain a digit';
28 return '';
29 default: return '';
30 }
31};
32
33export default function ErrorHandlingDemo() {
34 const [strategy, setStrategy] = useState<ValidationStrategy>('onBlur');
35 const [fields, setFields] = useState<Record<string, FieldState>>({
36 name: { value: '', error: '', touched: false },
37 email: { value: '', error: '', touched: false },
38 password: { value: '', error: '', touched: false },
39 });
40 const [submitted, setSubmitted] = useState(false);
41
42 const updateField = (name: string, value: string) => {
43 const error = strategy === 'onChange' ? validateField(name, value) : fields[name].error;
44 setFields(prev => ({ ...prev, [name]: { ...prev[name], value, error } }));
45 };
46
47 const blurField = (name: string) => {
48 if (strategy === 'onBlur' || strategy === 'onChange') {
49 const error = validateField(name, fields[name].value);
50 setFields(prev => ({ ...prev, [name]: { ...prev[name], touched: true, error } }));
51 }
52 };
53
54 const handleSubmit = (e: React.FormEvent) => {
55 e.preventDefault();
56 const newFields = { ...fields };
57 let valid = true;
58 for (const name of Object.keys(newFields)) {
59 const error = validateField(name, newFields[name].value);
60 newFields[name] = { ...newFields[name], error, touched: true };
61 if (error) valid = false;
62 }
63 setFields(newFields);
64 setSubmitted(valid);
65 };
66
67 const inputStyle = (hasError: boolean): React.CSSProperties => ({
68 width: '100%', padding: '10px', background: '#0d1117',
69 border: `1px solid ${hasError ? '#f44336' : '#333'}`, borderRadius: 6,
70 color: '#e0e0e0', fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box' as const
71 });
72
73 const strategies: { value: ValidationStrategy; label: string }[] = [
74 { value: 'onSubmit', label: 'On submit' },
75 { value: 'onBlur', label: 'On blur (leaving the field)' },
76 { value: 'onChange', label: 'On every change' },
77 ];
78
79 return (
80 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
81 <h1 style={{ color: '#64ffda' }}>Handling Form Errors</h1>
82
83 <div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
84 {strategies.map(s => (
85 <button key={s.value} onClick={() => {
86 setStrategy(s.value);
87 setFields(Object.fromEntries(Object.keys(fields).map(k => [k, { value: '', error: '', touched: false }])));
88 setSubmitted(false);
89 }} style={{
90 background: strategy === s.value ? '#1565c0' : '#1a2744',
91 color: '#fff', border: `1px solid ${strategy === s.value ? '#64ffda' : '#333'}`,
92 borderRadius: 6, padding: '6px 12px', cursor: 'pointer', fontSize: 11, fontFamily: 'monospace'
93 }}>
94 {s.label}
95 </button>
96 ))}
97 </div>
98
99 <form onSubmit={handleSubmit} style={{ maxWidth: 400 }}>
100 {Object.entries(fields).map(([name, field]) => (
101 <div key={name} style={{ marginBottom: 12 }}>
102 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>{name}</label>
103 <input
104 type={name === 'password' ? 'password' : 'text'}
105 value={field.value}
106 onChange={e => updateField(name, e.target.value)}
107 onBlur={() => blurField(name)}
108 style={inputStyle(!!field.error && field.touched)}
109 />
110 {field.error && field.touched && (
111 <div style={{ color: '#f44336', fontSize: 11, marginTop: 4, display: 'flex', alignItems: 'center', gap: 4 }}>
112 <span>⚠</span> {field.error}
113 </div>
114 )}
115 </div>
116 ))}
117 <button type="submit" style={{
118 background: '#f44336', color: '#fff', border: 'none', borderRadius: 6,
119 padding: '10px 20px', cursor: 'pointer', fontFamily: 'monospace', width: '100%'
120 }}>
121 Submit
122 </button>
123 </form>
124
125 {submitted && (
126 <div style={{ marginTop: 16, background: '#1b5e20', borderRadius: 8, padding: 12, maxWidth: 400, border: '1px solid #4caf50' }}>
127 Form submitted successfully!
128 </div>
129 )}
130 </div>
131 );
132}Spotted a mistake in this lesson?
Hands-on tasks in the game
- Horizontal ordering
Arrange the Zod validation chain for an email field with a minimum of 5 characters
- Click in order
Arrange the form configuration syntax with Zod resolver