Kurs Next.js · Moduł 6: Formularze i Server Actions

Obsługa błędów w Server Actions

7 min czytania
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.coerce do 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?

Przydatne artykuły