Kurs Next.js · Moduł 6: Formularze i Server Actions
Obsługa błędów formularzy i komunikaty
W tej lekcji8
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.
Rodzaje błędów w formularzach
Przed zagłębieniem się w szczegóły implementacji, warto zrozumieć różne rodzaje błędów, które mogą wystąpić w formularzach:
- Błędy walidacji - związane z nieprawidłowymi danymi wprowadzonymi przez użytkownika (np. niepoprawny format email)
- Błędy serwera - problemy podczas przetwarzania danych po stronie serwera (np. błąd bazy danych)
- Błędy sieci - problemy z połączeniem (np. timeout, offline)
- Błędy biznesowe - dane są technicznie poprawne, ale naruszają reguły biznesowe (np. duplikat adresu email)
- Błędy dostępności - formularz jest trudny lub niemożliwy do użycia dla osób z niepełnosprawnościami
Projektowanie przyjaznych komunikatów błędów
Dobrze zaprojektowane komunikaty błędów powinny spełniać następujące kryteria:
1. Być jasne i konkretne
Źle: "Błąd walidacji" Dobrze: "Hasło musi zawierać co najmniej 8 znaków, w tym cyfrę i wielką literę"
2. Używać języka przyjaznego dla użytkownika
Ź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."
3. Sugerować rozwiązanie
Źle: "Niepoprawny format numeru telefonu" Dobrze: "Wprowadź numer telefonu w formacie 123-456-789"
4. Być widoczne w odpowiednim miejscu
Komunikaty błędów powinny być wyświetlane blisko problematycznego pola, a nie tylko na górze formularza.
5. Mieć odpowiedni kontrast i dostępność
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.
Implementacja obsługi błędów w formularzach React
Przejdźmy do praktycznych przykładów implementacji obsługi błędów w różnych podejściach do formularzy.
Natywne formularze React
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:
- Stan błędów - osobny obiekt przechowujący błędy dla poszczególnych pól i ogólny błąd
- Atrybuty dostępności -
aria-invalidiaria-describedbydla czytników ekranu - Dynamiczna walidacja - usuwanie błędów podczas edycji pola
- Wskazówki wizualne - czerwona obwódka dla niepoprawnych pól
- Komunikaty pomocnicze - wskazówki dotyczące zasad hasła przed wystąpieniem błędu
Obsługa błędów z React Hook Form
React 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:
- Walidacja z Zod - czytelne i typowane schematy walidacji
- Wiele stanów formularza -
isSubmitting,touchedFields,isDirty - Tryby walidacji - konfiguracja z
mode: 'onTouched' - Wizualne wskaźniki stanu - zielona obwódka dla poprawnych, czerwona dla błędnych pól
- Integracja z ARIA - pełna dostępność forŭmularza
Obsługa błędów w Server Actions (Next.js)
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:
- Struktura błędów - specjalny format z błędami dla pól i ogólnym błędem formularza
- useFormState i useFormStatus - hooki do śledzenia stanu formularza i procesu wysyłki
- Focus po sukcesie - przesunięcie fokusu do komunikatu sukcesu dla użytkowników czytników ekranu
- Struktura danych zwrotnych - spójny format odpowiedzi z serwera
- Wiele rodzajów błędów - różne scenariusze błędów dla różnych przyczyn
Zaawansowane wzorce obsługi błędów
Konteksty błędów formularza
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}Komponent błędu formularza (reużywalny)
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}Komponent pola formularza (reużywalny)
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}Przykład użycia reużywalnych komponentów
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}Strategie obsługi błędów formularzy i ich wpływ na UX
Różne strategie obsługi błędów mogą znacząco wpłynąć na doświadczenie użytkownika:
1. Walidacja w czasie rzeczywistym vs przy wysyłce
Walidacja w czasie rzeczywistym:
- Zalety: Natychmiastowa informacja zwrotna, mniej frustracji przy wysyłce
- Wady: Może być przytłaczająca i irytująca, jeśli pojawia się zbyt wcześnie
Walidacja przy wysyłce:
- Zalety: Mniej rozpraszająca, pozwala użytkownikowi najpierw wprowadzić dane
- Wady: Może być frustrująca, jeśli użytkownik wypełnia długi formularz, a potem musi wracać do poprawek
Najlepsze podejście: Kompromis - walidacja przy utracie fokusu lub po pierwszej próbie wysyłki
2. Walidacja po stronie klienta vs serwera
Walidacja po stronie klienta:
- Zalety: Szybka, bez opóźnienia sieciowego, oszczędza zasoby serwera
- Wady: Może zostać ominięta, nie zawsze odzwierciedla faktyczne reguły biznesowe
Walidacja po stronie serwera:
- Zalety: Niezawodna, spójna, uwzględnia aktualny stan systemu
- Wady: Opóźniona informacja zwrotna, większe obciążenie serwera
Najlepsze podejście: Walidacja na obu poziomach - klient dla UX, serwer dla bezpieczeństwa
3. Walidacja pól vs formularza jako całości
Walidacja poszczególnych pól:
- Zalety: Precyzyjna informacja o błędach, łatwiejsze poprawianie
- Wady: Nie uwzględnia zależności między polami
Walidacja całego formularza:
- Zalety: Może uwzględniać zależności między polami
- Wady: Może być przytłaczająca, jeśli formularz ma wiele błędów
Najlepsze podejście: Walidacja pól podczas wprowadzania i pełna walidacja przy wysyłce
Dobre praktyki obsługi błędów formularzy
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-describedbyi 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
Obsługa błędów a dostępność
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 odczytane
Nie polegaj tylko na kolorach
- Dodaj ikony lub inne wskaźniki wizualne oprócz koloru czerwonego
- Zapewnij wystarczający kontrast między tekstem a tłem
Używaj
<fieldset>i<legend>- Grupuj powiązane pola za pomocą
<fieldset> - Opisuj grupy pól za pomocą
<legend>
- Grupuj powiązane pola za pomocą
Prawidłowa kolejność tabulacji
- Upewnij się, że użytkownicy klawiatury mogą poruszać się między polami w logicznej kolejności
Podsumowanie błędów
- Dla złożonych formularzy, dodaj podsumowanie błędów na górze formularza
- Każdy błąd powinien zawierać link do odpowiedniego pola
Podsumowanie
Efektywna obsługa błędów formularzy to znacznie więcej niż tylko informowanie użytkownika o problemach. To kompleksowe podejście, łączące:
- Projektowanie przyjaznych komunikatów - jasne, konkretne i pomocne
- Walidację na wielu poziomach - klient + serwer, poszczególne pola + cały formularz
- Odpowiedni timing - wybieranie optymalnego momentu na pokaźnie błędów
- Dostępność - zapewnienie, że wszyscy użytkownicy mogą zrozumieć i poprawić błędy
- Spójność wizualną - jednolite style i zachowania w całej aplikacji
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.
Kod do tej lekcji: App.tsx
1import React, { useState } from 'react';
2
3// Obsluga bledow formularzy - komunikaty, UX, strategie wyswietlania
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 'Imie jest wymagane';
17 if (value.length < 2) return 'Imie musi miec min. 2 znaki';
18 return '';
19 case 'email':
20 if (!value) return 'Email jest wymagany';
21 if (!/\S+@\S+\.\S+/.test(value)) return 'Nieprawidlowy format email';
22 return '';
23 case 'password':
24 if (!value) return 'Haslo jest wymagane';
25 if (value.length < 8) return 'Haslo musi miec min. 8 znakow';
26 if (!/[A-Z]/.test(value)) return 'Haslo musi zawierac wielka litere';
27 if (!/[0-9]/.test(value)) return 'Haslo musi zawierac cyfre';
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: 'Przy submit' },
75 { value: 'onBlur', label: 'Przy blur (opuszczeniu pola)' },
76 { value: 'onChange', label: 'Przy kazdej zmianie' },
77 ];
78
79 return (
80 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
81 <h1 style={{ color: '#64ffda' }}>Obsluga bledow formularzy</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 Wyslij
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 Formularz wyslany pomyslnie!
128 </div>
129 )}
130 </div>
131 );
132}Widzisz błąd w tej lekcji?
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż łańcuch walidacji Zod dla pola email z minimum 5 znakami
- Klikanie w kolejności
Ułóż składnię konfiguracji formularza z resolverem Zod