Kurs Next.js · Moduł 6: Formularze i Server Actions
Obsługa błędów w Server Actions
W tej lekcji8
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.
Try/catch w Server Actions
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.
Wzorzec zwracania obiektów błędów
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.
useActionState do wyświetlania błędów
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.
Walidacja z Zod w Server Actions
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 z.coerce.number() - konwertuje string z FormData na number przed walidacją. To kluczowe, bo formData.get() zawsze zwraca string.
Custom error types
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}Przyjazne komunikaty dla użytkownika
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ć."Fallback behaviors
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}Podsumowanie
Obsługa błędów w Server Actions to krytyczny element niezawodnych aplikacji Next.js:
- try/catch w każdej Server Action - nigdy nie pozwalaj, by surowe błędy dotarły do klienta
- Ustrukturyzowane obiekty błędów -
{ status, message, errors }zamiast rzucania wyjątków - useActionState - oficjalny hook do łączenia akcji z UI i wyświetlania błędów per pole
- Zod w Server Actions -
safeParse()+z.coercedo walidacji danych z FormData - Custom error types - rozróżnianie źródła błędów (walidacja, autoryzacja, baza danych)
- Przyjazne komunikaty - zrozumiałe, konkretne, z instrukcją co robić dalej
Kod do tej lekcji: App.tsx
1import React, { useState } from 'react';
2
3// Symulacja obslugi bledow w Server Actions (Next.js)
4// try/catch, obiekty bledow, useActionState, walidacja Zod
5
6type ActionState = {
7 status: 'idle' | 'success' | 'error';
8 message: string;
9 errors?: Record<string, string[]>;
10};
11
12// Uproszczona walidacja (symulacja Zod safeParse)
13function validateImplant(data: Record<string, string>) {
14 const errors: Record<string, string[]> = {};
15 if (!data.name || data.name.length < 2) {
16 errors.name = ['Nazwa musi miec co najmniej 2 znaki'];
17 }
18 if (!data.powerLevel || isNaN(Number(data.powerLevel))) {
19 errors.powerLevel = ['Wymagana liczba'];
20 } else {
21 const pl = Number(data.powerLevel);
22 if (pl < 1 || pl > 100) errors.powerLevel = ['Poziom mocy: 1-100'];
23 }
24 if (!data.type) {
25 errors.type = ['Wybierz typ implantu'];
26 }
27 return { success: Object.keys(errors).length === 0, errors };
28}
29
30// Symulacja Server Action z try/catch
31async function createImplantAction(data: Record<string, string>): Promise<ActionState> {
32 await new Promise(r => setTimeout(r, 800));
33
34 // 1. Walidacja z Zod (safeParse)
35 const validation = validateImplant(data);
36 if (!validation.success) {
37 return { status: 'error', message: 'Walidacja nie powiodla sie', errors: validation.errors };
38 }
39
40 // 2. Symulacja bledu serwera
41 try {
42 if (data.name.toLowerCase() === 'error') {
43 throw new Error('Database connection failed');
44 }
45 if (data.name.toLowerCase() === 'auth') {
46 throw { type: 'AuthorizationError', message: 'Brak uprawnien do tworzenia implantow' };
47 }
48 return { status: 'success', message: `Implant "${data.name}" (moc: ${data.powerLevel}) utworzony!` };
49 } catch (error: any) {
50 // NIGDY nie zwracaj surowego error do klienta
51 if (error.type === 'AuthorizationError') {
52 return { status: 'error', message: error.message };
53 }
54 console.error('Server error:', error);
55 return { status: 'error', message: 'Blad serwera. Sprobuj ponownie pozniej.' };
56 }
57}
58
59export default function ServerActionsErrorDemo() {
60 const [values, setValues] = useState({ name: '', powerLevel: '', type: '' });
61 const [state, setState] = useState<ActionState>({ status: 'idle', message: '' });
62 const [isPending, setIsPending] = useState(false);
63
64 const handleSubmit = async (e: React.FormEvent) => {
65 e.preventDefault();
66 setIsPending(true);
67 setState({ status: 'idle', message: '' });
68 const result = await createImplantAction(values);
69 setState(result);
70 setIsPending(false);
71 };
72
73 const inputStyle: React.CSSProperties = {
74 width: '100%', padding: '10px', background: '#0d1117',
75 border: '1px solid #333', borderRadius: 6, color: '#e0e0e0',
76 fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box'
77 };
78
79 const types = ['neural', 'cybernetic', 'bionic'];
80
81 const codeExample = `// app/actions.ts
82"use server";
83
84export async function createImplant(prevState, formData) {
85 const parsed = implantSchema.safeParse({...});
86 if (!parsed.success) {
87 return { status: 'error', errors: parsed.error.issues };
88 }
89 try {
90 await db.implants.create(parsed.data);
91 return { status: 'success', message: 'Utworzono!' };
92 } catch (error) {
93 return { status: 'error', message: 'Blad serwera' };
94 }
95}`;
96
97 return (
98 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
99 <h1 style={{ color: '#64ffda' }}>Obsluga bledow w Server Actions</h1>
100 <p style={{ color: '#888', fontSize: 13, marginBottom: 12 }}>
101 Wpisz "error" w nazwe = blad serwera. "auth" = brak uprawnien. Puste pola = walidacja Zod.
102 </p>
103
104 <pre style={{ background: '#0d1117', border: '1px solid #333', borderRadius: 8, padding: 12, fontSize: 11, color: '#ce93d8', marginBottom: 16, whiteSpace: 'pre-wrap' }}>
105{codeExample}
106 </pre>
107
108 <form onSubmit={handleSubmit} style={{ maxWidth: 400 }}>
109 <div style={{ marginBottom: 12 }}>
110 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Nazwa implantu</label>
111 <input
112 value={values.name}
113 onChange={e => setValues(p => ({ ...p, name: e.target.value }))}
114 placeholder="Neural Link"
115 style={{ ...inputStyle, borderColor: state.errors?.name ? '#f44336' : '#333' }}
116 />
117 {state.errors?.name && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{state.errors.name[0]}</div>}
118 </div>
119
120 <div style={{ marginBottom: 12 }}>
121 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Poziom mocy (1-100)</label>
122 <input
123 value={values.powerLevel}
124 onChange={e => setValues(p => ({ ...p, powerLevel: e.target.value }))}
125 placeholder="75"
126 style={{ ...inputStyle, borderColor: state.errors?.powerLevel ? '#f44336' : '#333' }}
127 />
128 {state.errors?.powerLevel && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{state.errors.powerLevel[0]}</div>}
129 </div>
130
131 <div style={{ marginBottom: 12 }}>
132 <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Typ implantu</label>
133 <div style={{ display: 'flex', gap: 8 }}>
134 {types.map(t => (
135 <button key={t} type="button" onClick={() => setValues(p => ({ ...p, type: t }))} style={{
136 flex: 1, padding: '8px', borderRadius: 6, fontFamily: 'monospace', fontSize: 12, cursor: 'pointer',
137 background: values.type === t ? '#1565c0' : '#0d1117',
138 color: values.type === t ? '#fff' : '#aaa',
139 border: `1px solid ${values.type === t ? '#64ffda' : state.errors?.type ? '#f44336' : '#333'}`
140 }}>
141 {t}
142 </button>
143 ))}
144 </div>
145 {state.errors?.type && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{state.errors.type[0]}</div>}
146 </div>
147
148 <button type="submit" disabled={isPending} style={{
149 background: isPending ? '#555' : '#e91e63', color: '#fff', border: 'none', borderRadius: 6,
150 padding: '10px 20px', cursor: isPending ? 'wait' : 'pointer', fontFamily: 'monospace', width: '100%'
151 }}>
152 {isPending ? 'Walidacja na serwerze...' : 'createImplant() - Server Action'}
153 </button>
154 </form>
155
156 {state.status !== 'idle' && !state.errors && (
157 <div style={{
158 marginTop: 16, borderRadius: 8, padding: 12, maxWidth: 400,
159 background: state.status === 'success' ? '#1b5e20' : '#3e1010',
160 border: `1px solid ${state.status === 'success' ? '#4caf50' : '#f44336'}`
161 }}>
162 <div style={{ color: state.status === 'success' ? '#4caf50' : '#f44336' }}>
163 {state.message}
164 </div>
165 </div>
166 )}
167 </div>
168 );
169}Widzisz błąd w tej lekcji?