We use cookies to enhance your experience on the site
CodeWorlds

Form Error Handling and Messages

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:

  1. Validation errors - related to incorrect data entered by the user (e.g. invalid email format)
  2. Server errors - problems while processing data on the server side (e.g. database error)
  3. Network errors - connection problems (e.g. timeout, offline)
  4. Business errors - data is technically correct but violates business rules (e.g. duplicate email address)
  5. 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:

  1. Error state - a separate object storing errors for individual fields and a general error
  2. Accessibility attributes -
    aria-invalid
    and
    aria-describedby
    for screen readers
  3. Dynamic validation - removing errors while editing a field
  4. Visual cues - red border for invalid fields
  5. 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:

  1. Validation with Zod - readable and typed validation schemas
  2. Multiple form states -
    isSubmitting
    ,
    touchedFields
    ,
    isDirty
  3. Validation modes - configuration with
    mode: 'onTouched'
  4. Visual state indicators - green border for valid, red for invalid fields
  5. 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:

  1. Error structure - a special format with field errors and a general form error
  2. useFormState and useFormStatus - hooks to track form state and submission process
  3. Focus after success - moving focus to the success message for screen reader users
  4. Response data structure - consistent format for server responses
  5. 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

  1. Group related errors - place whole-form errors at the top, specific field errors next to the fields

  2. Maintain visual consistency - use the same colors, icons, and styles for errors throughout the application

  3. Perfect your messages - instead of "Invalid data", write exactly what's wrong and how to fix it

  4. Preserve data on errors - don't reset the entire form after an error occurs

  5. Prioritize errors - show critical errors first, then less important ones

  6. Auto-scroll to errors - especially in long forms, automatically scroll to the first error

  7. Use ARIA - use

    aria-invalid
    ,
    aria-describedby
    attributes and role="alert" for accessibility

  8. Mark required fields - clearly indicate which fields are required, ideally before the user starts filling out the form

  9. Provide examples - for complex formats (e.g., date, phone number) provide examples

  10. 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:

  1. Use ARIA attributes

    • aria-invalid="true"
      for invalid fields
    • aria-describedby
      linking the field to the error message
    • role="alert"
      for error messages that should be read immediately
  2. Don't rely on color alone

    • Add icons or other visual cues besides red color
    • Ensure sufficient contrast between text and background
  3. Use

    <fieldset>
    and
    <legend>

    • Group related fields using
      <fieldset>
    • Describe groups of fields using
      <legend>
  4. Correct tab order

    • Make sure keyboard users can move between fields in a logical order
  5. 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:

  1. Designing friendly messages - clear, specific, and helpful
  2. Multi-level validation - client + server, individual fields + whole form
  3. Appropriate timing - choosing the optimal moment to show errors
  4. Accessibility - ensuring all users can understand and correct errors
  5. 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.

Go to CodeWorlds