W Metropolis Quantum nawet najlepsze systemy mogą napotkać błędy — nieoczekiwane dane od użytkownika, problemy z bazą danych, czy awarie zewnętrznych API. Prawidłowa obsługa błędów w Server Actions to kluczowy element tworzenia niezawodnych aplikacji Next.js. W tej lekcji poznasz wzorce, które pozwolą Ci elegancko obsługiwać błędy i informować o nich użytkowników.
Podstawowym wzorcem obsługi błędów w Server Actions jest try/catch. Każda akcja serwerowa powinna przewidywać możliwość wystąpienia błędu:
1// app/actions.ts
2'use server';
3
4export async function createImplant(formData: FormData) {
5 try {
6 const name = formData.get('name') as string;
7 const powerLevel = Number(formData.get('powerLevel'));
8
9 // Operacja, która może się nie powieść
10 const result = await db.implants.create({ name, powerLevel });
11
12 return { success: true, data: result };
13 } catch (error) {
14 // NIGDY nie zwracaj surowego obiektu error do klienta!
15 console.error('Błąd tworzenia implantu:', error);
16 return {
17 success: false,
18 error: 'Nie udało się utworzyć implantu. Spróbuj ponownie.'
19 };
20 }
21}Kluczowa zasada: Server Actions nie powinny rzucać wyjątków do klienta. Zamiast tego zwracaj ustrukturyzowany obiekt z informacją o sukcesie lub błędzie.
W profesjonalnych aplikacjach warto zdefiniować spójny typ dla odpowiedzi z Server Actions:
1// lib/types.ts
2type ActionState = {
3 status: 'idle' | 'success' | 'error';
4 message: string;
5 errors?: Record<string, string[]>; // Błędy per pole formularza
6};
7
8// app/actions.ts
9'use server';
10
11export async function updateProfile(
12 prevState: ActionState,
13 formData: FormData
14): Promise<ActionState> {
15 const name = formData.get('name') as string;
16 const email = formData.get('email') as string;
17
18 // Walidacja
19 const errors: Record<string, string[]> = {};
20
21 if (!name || name.length < 2) {
22 errors.name = ['Imię musi mieć co najmniej 2 znaki'];
23 }
24 if (!email || !email.includes('@')) {
25 errors.email = ['Nieprawidłowy format adresu email'];
26 }
27
28 if (Object.keys(errors).length > 0) {
29 return {
30 status: 'error',
31 message: 'Formularz zawiera błędy',
32 errors,
33 };
34 }
35
36 try {
37 await db.users.update({ name, email });
38 return { status: 'success', message: 'Profil zaktualizowany!' };
39 } catch (error) {
40 return { status: 'error', message: 'Błąd serwera. Spróbuj ponownie.' };
41 }
42}Ten wzorzec — obiekt z polami
status, message i opcjonalnym errors — pozwala klientowi precyzyjnie zrozumieć, co poszło nie tak.Hook
useActionState (dawniej useFormState) to oficjalny sposób na łączenie Server Actions z interfejsem użytkownika w React/Next.js:1'use client';
2
3import { useActionState } from 'react';
4import { updateProfile } from './actions';
5
6const initialState = {
7 status: 'idle',
8 message: '',
9 errors: {},
10};
11
12export default function ProfileForm() {
13 const [state, formAction, isPending] = useActionState(
14 updateProfile,
15 initialState
16 );
17
18 return (
19 <form action={formAction}>
20 <div>
21 <label htmlFor="name">Imię</label>
22 <input id="name" name="name" />
23 {state.errors?.name && (
24 <p className="text-red-500">{state.errors.name[0]}</p>
25 )}
26 </div>
27
28 <div>
29 <label htmlFor="email">Email</label>
30 <input id="email" name="email" type="email" />
31 {state.errors?.email && (
32 <p className="text-red-500">{state.errors.email[0]}</p>
33 )}
34 </div>
35
36 <button type="submit" disabled={isPending}>
37 {isPending ? 'Zapisywanie...' : 'Zapisz profil'}
38 </button>
39
40 {state.status === 'success' && (
41 <p className="text-green-500">{state.message}</p>
42 )}
43 {state.status === 'error' && !state.errors && (
44 <p className="text-red-500">{state.message}</p>
45 )}
46 </form>
47 );
48}useActionState przyjmuje trzy argumenty: akcję serwerową, stan początkowy, i zwraca tablicę: aktualny stan, funkcję formAction (przekazujemy ją do action formularza) oraz flagę isPending.Połączenie Zod z Server Actions to potężny wzorzec — schemat walidacji jest współdzielony między klientem i serwerem:
1// lib/schemas.ts
2import { z } from 'zod';
3
4export const implantSchema = z.object({
5 name: z.string()
6 .min(2, 'Nazwa implantu musi mieć co najmniej 2 znaki')
7 .max(50, 'Nazwa implantu może mieć maksymalnie 50 znaków'),
8 powerLevel: z.coerce.number()
9 .min(1, 'Poziom mocy musi być większy od 0')
10 .max(100, 'Poziom mocy nie może przekraczać 100'),
11 type: z.enum(['neural', 'cybernetic', 'bionic'], {
12 errorMap: () => ({ message: 'Wybierz typ implantu' }),
13 }),
14});
15
16// app/actions.ts
17'use server';
18
19import { implantSchema } from '@/lib/schemas';
20
21export async function createImplant(prevState: any, formData: FormData) {
22 // Walidacja z Zod
23 const parsed = implantSchema.safeParse({
24 name: formData.get('name'),
25 powerLevel: formData.get('powerLevel'),
26 type: formData.get('type'),
27 });
28
29 if (!parsed.success) {
30 // Konwertujemy błędy Zod na format field -> messages
31 const fieldErrors: Record<string, string[]> = {};
32 for (const issue of parsed.error.issues) {
33 const field = issue.path[0] as string;
34 if (!fieldErrors[field]) fieldErrors[field] = [];
35 fieldErrors[field].push(issue.message);
36 }
37 return { status: 'error', message: 'Walidacja nie powiodła się', errors: fieldErrors };
38 }
39
40 try {
41 // parsed.data jest typowane i zwalidowane!
42 await db.implants.create(parsed.data);
43 return { status: 'success', message: 'Implant utworzony!' };
44 } catch (error) {
45 return { status: 'error', message: 'Błąd zapisu do bazy danych' };
46 }
47}Zwróć uwagę na
— konwertuje string z FormData na number przed walidacją. To kluczowe, bo z.coerce.number()
formData.get() zawsze zwraca string.W złożonych aplikacjach warto definiować własne typy błędów, by rozróżniać ich źródło:
1// lib/errors.ts
2export class ValidationError extends Error {
3 constructor(
4 public fieldErrors: Record<string, string[]>,
5 message = 'Walidacja nie powiodła się'
6 ) {
7 super(message);
8 this.name = 'ValidationError';
9 }
10}
11
12export class DatabaseError extends Error {
13 constructor(message = 'Błąd bazy danych') {
14 super(message);
15 this.name = 'DatabaseError';
16 }
17}
18
19export class AuthorizationError extends Error {
20 constructor(message = 'Brak uprawnień') {
21 super(message);
22 this.name = 'AuthorizationError';
23 }
24}
25
26// app/actions.ts
27'use server';
28
29import { ValidationError, DatabaseError, AuthorizationError } from '@/lib/errors';
30
31export async function deleteImplant(prevState: any, formData: FormData) {
32 try {
33 const id = formData.get('id') as string;
34
35 if (!id) {
36 throw new ValidationError({ id: ['ID implantu jest wymagane'] });
37 }
38
39 const user = await getCurrentUser();
40 if (!user?.isAdmin) {
41 throw new AuthorizationError('Tylko administratorzy mogą usuwać implanty');
42 }
43
44 await db.implants.delete(id);
45 return { status: 'success', message: 'Implant usunięty' };
46
47 } catch (error) {
48 if (error instanceof ValidationError) {
49 return { status: 'error', message: error.message, errors: error.fieldErrors };
50 }
51 if (error instanceof AuthorizationError) {
52 return { status: 'error', message: error.message };
53 }
54 if (error instanceof DatabaseError) {
55 return { status: 'error', message: 'Błąd systemu. Spróbuj ponownie później.' };
56 }
57 // Nieznany błąd — nie ujawniaj szczegółów użytkownikowi
58 console.error('Nieoczekiwany błąd:', error);
59 return { status: 'error', message: 'Wystąpił nieoczekiwany błąd' };
60 }
61}Komunikaty błędów powinny być zrozumiałe i pomocne — użytkownik musi wiedzieć, co zrobić:
1// Złe komunikaty:
2"Error: ECONNREFUSED 127.0.0.1:5432" // Techniczny, niezrozumiały
3"Validation failed" // Za ogólny
4"null" // Bezsensowny
5
6// Dobre komunikaty:
7"Nie udało się zapisać danych. Sprawdź połączenie i spróbuj ponownie."
8"Nazwa implantu musi mieć co najmniej 2 znaki."
9"Sesja wygasła. Zaloguj się ponownie, aby kontynuować."Gdy Server Action nie powiedzie się, aplikacja powinna mieć plan awaryjny:
1'use client';
2
3import { useActionState } from 'react';
4import { saveData } from './actions';
5
6export default function FormWithFallback() {
7 const [state, formAction, isPending] = useActionState(saveData, {
8 status: 'idle',
9 message: '',
10 });
11
12 // Fallback: retry po błędzie
13 const handleRetry = () => {
14 // Resetuj stan i pozwól użytkownikowi spróbować ponownie
15 window.location.reload();
16 };
17
18 if (state.status === 'error' && state.message.includes('serwera')) {
19 return (
20 <div>
21 <p>Wystąpił problem z serwerem.</p>
22 <button onClick={handleRetry}>Spróbuj ponownie</button>
23 <p>Jeśli problem się powtarza, skontaktuj się z administratorem.</p>
24 </div>
25 );
26 }
27
28 return (
29 <form action={formAction}>
30 {/* ... pola formularza ... */}
31 <button type="submit" disabled={isPending}>
32 {isPending ? 'Zapisywanie...' : 'Zapisz'}
33 </button>
34 </form>
35 );
36}Obsługa błędów w Server Actions to krytyczny element niezawodnych aplikacji Next.js:
{ status, message, errors } zamiast rzucania wyjątkówsafeParse() + z.coerce do walidacji danych z FormData