Efektywna obsługa błędów formularzy to kluczowy element tworzenia przyjaznych dla użytkownika aplikacji webowych. Dobrze zaprojektowane komunikaty błędów nie tylko informują użytkownika o problemach, ale także wskazują, jak je rozwiązać. W tym module omowimy najlepsze praktyki obsługi błędów w formularzach React i Next.js, od projektowania przyjaznych komunikatów aż po zaawansowane techniki walidacji.
Przed zagłębieniem się w szczegóły implementacji, warto zrozumieć różne rodzaje błędów, które mogą wystąpić w formularzach:
Dobrze zaprojektowane komunikaty błędów powinny spełniać następujące kryteria:
Źle: "Błąd walidacji" Dobrze: "Hasło musi zawierać co najmniej 8 znaków, w tym cyfrę i wielką literę"
Źle: "Wystąpił błąd parsowania JSON: nieoczekiwany token na pozycji 42" Dobrze: "Wystąpił problem techniczny. Spróbuj ponownie lub skontaktuj się z nami."
Źle: "Niepoprawny format numeru telefonu" Dobrze: "Wprowadź numer telefonu w formacie 123-456-789"
Komunikaty błędów powinny być wyświetlane blisko problematycznego pola, a nie tylko na górze formularza.
Komunikaty błędów powinny być dobrze widoczne i dostępne dla wszystkich użytkowników, w tym osób korzystających z czytników ekranu.
Przejdźmy do praktycznych przykładów implementacji obsługi błędów w różnych podejściach do formularzy.
W natywnych formularzach React, błędy możemy obsługiwać za pomocą stanu:
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 // Walidacja imienia
26 if (!formData.name.trim()) {
27 newErrors.name = 'Imię jest wymagane';
28 isValid = false;
29 }
30
31 // Walidacja email
32 if (!formData.email.trim()) {
33 newErrors.email = 'Email jest wymagany';
34 isValid = false;
35 } else if (!/^S+@S+.S+$/.test(formData.email)) {
36 newErrors.email = 'Podaj prawidłowy adres email';
37 isValid = false;
38 }
39
40 // Walidacja hasła
41 if (!formData.password) {
42 newErrors.password = 'Hasło jest wymagane';
43 isValid = false;
44 } else if (formData.password.length < 8) {
45 newErrors.password = 'Hasło musi mieć co najmniej 8 znaków';
46 isValid = false;
47 } else if (!/[A-Z]/.test(formData.password)) {
48 newErrors.password = 'Hasło musi zawierać co najmniej jedną wielką literę';
49 isValid = false;
50 } else if (!/[0-9]/.test(formData.password)) {
51 newErrors.password = 'Hasło musi zawierać co najmniej jedną cyfrę';
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 // Aktualizacja danych formularza
63 setFormData(prev => ({
64 ...prev,
65 [name]: value
66 }));
67
68 // Usunięcie błędu dla edytowanego pola
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 // Resetowanie stanu przed wysyłką
82 setErrors({});
83 setSubmitSuccess(false);
84
85 // Walidacja formularza
86 if (!validateForm()) {
87 return;
88 }
89
90 setIsSubmitting(true);
91
92 try {
93 // Symulacja wysyłki do API z losowym błędem
94 await new Promise(resolve => setTimeout(resolve, 1000));
95
96 // Symulacja losowego błędu serwera (25% szans)
97 if (Math.random() < 0.25) {
98 throw new Error('Wystąpił błąd podczas przetwarzania formularza');
99 }
100
101 // Symulacja błędu biznesowego - już istniejący email (25% szans)
102 if (Math.random() < 0.25) {
103 setErrors({
104 email: 'Ten adres email jest już zarejestrowany'
105 });
106 return;
107 }
108
109 // Sukces
110 setSubmitSuccess(true);
111 setFormData({ name: '', email: '', password: '' });
112 console.log('Formularz wysłany pomyślnie:', formData);
113 } catch (error) {
114 // Obsługa błędu serwera
115 setErrors({
116 generalError: 'Wystąpił problem podczas przetwarzania formularza. Spróbuj ponownie później.'
117 });
118 console.error('Błąd wysyłki formularza:', 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">Rejestracja</h1>
127
128 {submitSuccess && (
129 <div className="mb-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded">
130 Rejestracja przebiegła pomyślnie! Sprawdź swój email w celu aktywacji konta.
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">Wystąpił błąd</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 Imię i nazwisko
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 Hasło
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 Hasło powinno mieć co najmniej 8 znaków, zawierać wielką literę i cyfrę.
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 ? 'Przetwarzanie...' : 'Zarejestruj się'}
215 </button>
216 </form>
217 </div>
218 );
219}Kluczowe elementy obsługi błędów w tym przykładzie:
aria-invalid i aria-describedby dla czytników ekranuReact Hook Form oferuje zaawansowane możliwości obsługi błędów:
1import { useForm, SubmitHandler } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4import { useState } from 'react';
5
6// Schemat walidacji
7const signupSchema = z.object({
8 name: z.string().min(1, { message: 'Imię jest wymagane' }),
9 email: z.string().email({ message: 'Podaj prawidłowy adres email' }),
10 password: z.string()
11 .min(8, { message: 'Hasło musi mieć co najmniej 8 znaków' })
12 .regex(/[A-Z]/, { message: 'Hasło musi zawierać co najmniej jedną wielką literę' })
13 .regex(/[0-9]/, { message: 'Hasło musi zawierać co najmniej jedną cyfrę' })
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' // Walidacja przy utracie fokusu
30 });
31
32 const onSubmit: SubmitHandler<SignupFormInputs> = async (data) => {
33 // Resetujemy błędy serwera i sukces przed nową próbą
34 setServerError(null);
35 setSubmitSuccess(false);
36
37 try {
38 // Symulacja wysyłki do API
39 await new Promise(resolve => setTimeout(resolve, 1000));
40
41 // Symulacja losowego błędu serwera (25% szans)
42 if (Math.random() < 0.25) {
43 throw new Error('Wystąpił błąd podczas przetwarzania formularza');
44 }
45
46 // Symulacja błędu biznesowego - już istniejący email (25% szans)
47 if (Math.random() < 0.25) {
48 throw new Error('Ten adres email jest już zarejestrowany');
49 }
50
51 // Sukces
52 console.log('Formularz wysłany pomyślnie:', data);
53 setSubmitSuccess(true);
54 reset(); // Czyszczenie formularza
55 } catch (error) {
56 if (error instanceof Error) {
57 setServerError(error.message);
58 } else {
59 setServerError('Wystąpił nieznany błąd. Spróbuj ponownie później.');
60 }
61 console.error('Błąd wysyłki formularza:', 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">Rejestracja (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 Rejestracja przebiegła pomyślnie! Sprawdź swój email w celu aktywacji konta.
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">Wystąpił błąd</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 Imię i nazwisko
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 Hasło
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 Hasło powinno mieć co najmniej 8 znaków, zawierać wielką literę i cyfrę.
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 ? 'Przetwarzanie...' : 'Zarejestruj się'}
150 </button>
151 </form>
152 </div>
153 );
154}Kluczowe elementy obsługi błędów w React Hook Form:
isSubmitting, touchedFields, isDirtymode: 'onTouched'W formularzach korzystających z Server Actions w Next.js, obsługa błędów jest szczególnie istotna, ponieważ procesowanie odbywa się na serwerze:
1// app/actions.ts
2'use server';
3
4import { z } from 'zod';
5
6const contactSchema = z.object({
7 name: z.string().min(1, { message: 'Imię jest wymagane' }),
8 email: z.string().email({ message: 'Podaj prawidłowy adres email' }),
9 subject: z.string().min(3, { message: 'Temat jest zbyt krótki' }),
10 message: z.string().min(10, { message: 'Wiadomość musi mieć co najmniej 10 znaków' })
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 // Konwersja FormData na obiekt
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 // Walidacja danych
38 const validationResult = contactSchema.safeParse(rawFormData);
39
40 // Jeśli walidacja nie powiedzie się
41 if (!validationResult.success) {
42 // Konwersja błędów Zod na format dla formularza
43 return {
44 errors: validationResult.error.flatten().fieldErrors,
45 message: 'Proszę poprawić błędy w formularzu',
46 success: false
47 };
48 }
49
50 try {
51 // Symulacja opóźnienia API
52 await new Promise(resolve => setTimeout(resolve, 1000));
53
54 // Symulacja losowego błędu serwera (15% szans)
55 if (Math.random() < 0.15) {
56 throw new Error('Problem z połączeniem z serwerem');
57 }
58
59 // Symulacja błędu spamowego (10% szans)
60 if (Math.random() < 0.1) {
61 return {
62 errors: {
63 _form: ['Wykryto możliwy spam. Spróbuj ponownie później.']
64 },
65 success: false
66 };
67 }
68
69 // Symulacja przetwarzania formularza
70 console.log('Przetwarzanie formularza kontaktowego:', validationResult.data);
71
72 return {
73 message: 'Wiadomość została wysłana pomyślnie. Odpowiemy najszybciej jak to możliwe.',
74 success: true
75 };
76 } catch (error) {
77 // Obsługa błędów serwera
78 return {
79 errors: {
80 _form: [
81 'Wystąpił błąd podczas przetwarzania formularza. ' +
82 'Spróbuj ponownie później lub skontaktuj się z nami bezpośrednio.'
83 ]
84 },
85 success: false
86 };
87 }
88}I komponent używający tej akcji:
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// Komponent przycisku z obsługą stanu wysyłania
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 ? 'Wysyłanie...' : 'Wyślij wiadomość'}
20 </button>
21 );
22}
23
24export default function ContactForm() {
25 const formRef = useRef<HTMLFormElement>(null);
26
27 // Inicjalizacja stanu formularza
28 const initialState: ContactFormState = { errors: {} };
29
30 // Połączenie z Server Action
31 const [state, formAction] = useFormState(submitContactForm, initialState);
32
33 // Resetowanie formularza po pomyślnym wysłaniu
34 useEffect(() => {
35 if (state.success && formRef.current) {
36 formRef.current.reset();
37
38 // Skupienie się na komunikacie sukcesu dla czytników ekranu
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">Skontaktuj się z nami</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 Imię i nazwisko
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 Temat
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 Wiadomość
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}Kluczowe elementy obsługi błędów z Server Actions:
Dla bardziej złożonych formularzy, warto rozważyć utworzenie kontekstu błędów:
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}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}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 // Możemy użyć kontekstu błędów, jeśli błędy nie są przekazane jako prop
27 const { getFieldErrors } = useFormErrors();
28 const errors = propErrors || getFieldErrors(name);
29
30 const hasError = errors && errors.length > 0;
31
32 // Określ, który element opisuje to pole (błąd lub tekst pomocniczy)
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 // Jeśli przekazano dzieci (np. niestandardowe pola)
44 children
45 ) : (
46 // Domyślnie renderuj inputa
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 // Pokaż błędy
60 <FormError errors={errors} id={errorId} />
61 ) : helpText ? (
62 // Pokaż tekst pomocniczy jeśli nie ma błędów
63 <p className="mt-1 text-xs text-gray-500" id={helpId}>
64 {helpText}
65 </p>
66 ) : null}
67 </div>
68 );
69}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">Rejestracja</h1>
10
11 <form className="space-y-4" noValidate>
12 <FormField
13 label="Imię i nazwisko"
14 name="name"
15 type="text"
16 required
17 helpText="Wprowadź swoje pełne imię i nazwisko"
18 />
19
20 <FormField
21 label="Email"
22 name="email"
23 type="email"
24 required
25 helpText="Ten adres będzie służył do logowania do konta"
26 />
27
28 <FormField
29 label="Hasło"
30 name="password"
31 type="password"
32 required
33 helpText="Hasło powinno mieć co najmniej 8 znaków, zawierać wielką literę i cyfrę"
34 />
35
36 {/* Niestandardowe pole z własną implementacją */}
37 <FormField
38 label="Kategoria konta"
39 name="accountType"
40 helpText="Wybierz typ konta odpowiedni dla Twoich potrzeb"
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 Osobiste
46 </label>
47 <label className="inline-flex items-center">
48 <input type="radio" name="accountType" value="business" className="mr-2" />
49 Biznesowe
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 Zarejestruj się
59 </button>
60 </form>
61 </div>
62 </FormErrorProvider>
63 );
64}Różne strategie obsługi błędów mogą znacząco wpłynąć na doświadczenie użytkownika:
Walidacja w czasie rzeczywistym:
Walidacja przy wysyłce:
Najlepsze podejście: Kompromis - walidacja przy utracie fokusu lub po pierwszej próbie wysyłki
Walidacja po stronie klienta:
Walidacja po stronie serwera:
Najlepsze podejście: Walidacja na obu poziomach - klient dla UX, serwer dla bezpieczeństwa
Walidacja poszczególnych pól:
Walidacja całego formularza:
Najlepsze podejście: Walidacja pól podczas wprowadzania i pełna walidacja przy wysyłce
Grupuj powiązane błędy - błędy dotyczące całego formularza umieszczaj na górze, błędy konkretnych pól - przy polach
Zachowaj spójność wizualną - używaj tych samych kolorów, ikon i styli dla błędów w całej aplikacji
Doskonal komunikaty - zamiast "Nieprawidłowe dane" napisz dokładnie, co jest nie tak i jak to naprawić
Zachowaj dane przy błędach - nie resetuj całego formularza po wystąpieniu błędu
Priorytetyzuj błędy - najpierw pokaż krytyczne błędy, potem mniej istotne
Automatycznie przewijaj do błędów - szczególnie w długich formularzach, automatycznie przewijaj do pierwszego błędu
Wykorzystuj ARIA - używaj atrybutów
aria-invalid, aria-describedby i role="alert" dla dostępnościOznaczaj wymagane pola - jasno oznacz, które pola są wymagane, najlepiej przed wypełnianiem formularza
Podawaj przykłady - dla skomplikowanych formatów (np. data, numer telefonu) podawaj przykłady
Testuj z użytkownikami - regularnie testuj formularze z rzeczywistymi użytkownikami, aby zidentyfikować problemy
Efektywna obsługa błędów jest szczególnie ważna dla użytkowników z niepełnosprawnościami:
Używaj atrybutów ARIA
aria-invalid="true" dla niepoprawnych pólaria-describedby łączący pole z komunikatem błędurole="alert" dla komunikatów błędów, które powinny być natychmiast odczytaneNie polegaj tylko na kolorach
Używaj
i <fieldset><legend>
<fieldset><legend>Prawidłowa kolejność tabulacji
Podsumowanie błędów
Efektywna obsługa błędów formularzy to znacznie więcej niż tylko informowanie użytkownika o problemach. To kompleksowe podejście, łączące:
Pamiętaj, że cel to nie tylko wykrycie błędów, ale przede wszystkim pomoc użytkownikowi w ich skutecznym poprawieniu i pomyślnym ukończeniu formularza. Dobrze zaprojektowana obsługa błędów może znacząco poprawić współczynnik konwersji i satysfakcję użytkowników.