Kurs Next.js · Moduł 9: Integracje i zaawansowane funkcje
Server Actions - zaawansowane wzorce
W tej lekcji7
Klikasz "Dodaj zadanie" w panelu Metropolii Quantum 2150 i... nic. Lista stoi w miejscu, bo formularz czeka na serwer. Użytkownik klika drugi raz i powstaje duplikat. Podstawy Server Actions już znasz: funkcja z dyrektywą 'use server' wywoływana prosto z komponentu React, bez osobnego endpointu API. Teraz poznasz wzorce, które odróżniają prototyp od niezawodnej aplikacji.
Optimistic Updates z useOptimistic
Hook useOptimistic z React 19 pokazuje wynik operacji, zanim serwer ją potwierdzi. Przyjmuje aktualny stan i funkcję łączącą go ze zmianą, a zwraca stan do wyświetlenia oraz funkcję dodającą zmianę. Gdy akcja się skończy, React wraca do prawdziwych danych, więc przy błędzie stan sam wraca do poprzedniego.
Podstawowy wzorzec
Najpierw opisujemy zadanie interfejsem Task (id, tytuł, flaga ukończenia), a potem podpinamy useOptimistic pod akcję formularza w komponencie klienckim:
1// app/tasks/TaskList.tsx
2'use client';
3
4import { useOptimistic } from 'react';
5import { addTask, toggleTask } from './actions';
6
7interface Task {
8 id: string;
9 title: string;
10 completed: boolean;
11}
12
13export default function TaskList({ tasks }: { tasks: Task[] }) {
14 const [optimisticTasks, addOptimisticTask] = useOptimistic(
15 tasks,
16 (state: Task[], newTask: Task) => [...state, newTask]
17 );
18
19 async function handleAddTask(formData: FormData) {
20 const title = formData.get('title') as string;
21
22 // Natychmiastowa aktualizacja UI
23 addOptimisticTask({
24 id: 'temp-' + Date.now(),
25 title,
26 completed: false,
27 });
28
29 // Wywołanie Server Action
30 await addTask(formData);
31 }
32
33 return (
34 <div>
35 <form action={handleAddTask}>
36 <input name="title" placeholder="Nowe zadanie..." />
37 <button type="submit">Dodaj</button>
38 </form>
39
40 <ul>
41 {optimisticTasks.map((task) => (
42 <li key={task.id}>{task.title}</li>
43 ))}
44 </ul>
45 </div>
46 );
47}Nowe zadanie pojawia się od razu z tymczasowym id, a dopiero potem wywołujemy addTask. Kod NIE modyfikuje propsa tasks: prawdziwa lista przyjdzie z serwera i zastąpi wersję tymczasową. Funkcję z useOptimistic wolno wołać tylko wewnątrz akcji lub przejścia (transition), dlatego robimy to w handleAddTask.
Server Action z optimistic toggle
Po stronie serwera, w pliku z 'use server', jedna funkcja tworzy zadanie, a druga przełącza jego stan:
1// app/tasks/actions.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5import { db } from '@/lib/db';
6
7export async function addTask(formData: FormData) {
8 const title = formData.get('title') as string;
9
10 await db.tasks.create({
11 data: { title, completed: false },
12 });
13
14 revalidatePath('/tasks');
15}
16
17export async function toggleTask(taskId: string) {
18 const task = await db.tasks.findUnique({ where: { id: taskId } });
19
20 await db.tasks.update({
21 where: { id: taskId },
22 data: { completed: !task?.completed },
23 });
24
25 revalidatePath('/tasks');
26}Obie zapisują dane i wołają revalidatePath('/tasks'), więc strona wygeneruje się ze świeżą listą. toggleTask możesz podpiąć pod checkbox tym samym wzorcem optymistycznym.
Rewalidacja po mutacji
Po zmianie danych cache Next.js wciąż pamięta starą wersję strony. Odświeżysz go ścieżką (revalidatePath) albo tagiem (revalidateTag). To uzupełnienie ISR (Incremental Static Regeneration), czyli statycznego generowania z okresową aktualizacją: ISR odświeża co określony czas, rewalidacja na żądanie od razu po mutacji.
revalidatePath - odświeżanie ścieżki
Najprostszy wariant przyjmuje adres strony, a opcjonalny drugi argument 'layout' obejmuje layout i wszystkie strony pod nim:
1// actions/products.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5
6export async function updateProduct(productId: string, formData: FormData) {
7 const name = formData.get('name') as string;
8 const price = parseFloat(formData.get('price') as string);
9
10 await db.products.update({
11 where: { id: productId },
12 data: { name, price },
13 });
14
15 // Odśwież konkretną stronę produktu
16 revalidatePath('/products/' + productId);
17
18 // Odśwież listę produktów
19 revalidatePath('/products');
20
21 // Odśwież cały layout
22 revalidatePath('/', 'layout');
23}Rewalidacja nie pobiera danych ponownie od razu, tylko oznacza cache jako nieaktualny. Świeże dane załadują się przy następnej wizycie.
revalidateTag - odświeżanie po tagu
Gdy te same dane są na wielu stronach, wygodniej oznaczyć je tagiem. Tutaj funkcje pobierające produkty owija unstable_cache z listą tagów:
1// lib/data.ts
2import { unstable_cache } from 'next/cache';
3
4export const getProducts = unstable_cache(
5 async () => {
6 return await db.products.findMany();
7 },
8 ['products'],
9 { tags: ['products'] }
10);
11
12export const getProduct = unstable_cache(
13 async (id: string) => {
14 return await db.products.findUnique({ where: { id } });
15 },
16 ['product'],
17 { tags: ['products', 'product-detail'] }
18);W Next.js 16 unstable_cache należy do starszego modelu i zastępuje go dyrektywa 'use cache' z funkcją cacheTag, którą poznasz w lekcji o cachowaniu. Tagi działają tak samo w obu modelach.
Akcja usuwająca produkt unieważnia teraz wszystkie dane z tagiem products jednym wywołaniem:
1// actions/products.ts
2'use server';
3
4import { revalidateTag } from 'next/cache';
5
6export async function deleteProduct(productId: string) {
7 await db.products.delete({ where: { id: productId } });
8
9 // Odśwież wszystkie dane oznaczone tagiem 'products'
10 revalidateTag('products', 'max');
11}Drugi argument 'max' to profil zalecany w Next.js 16: użytkownik dostaje starą wersję, a świeża ładuje się w tle. Wywołanie z samym tagiem jest przestarzałe. Jeśli użytkownik musi od razu zobaczyć własną zmianę, użyj w Server Action updateTag('products').
Obsługa błędów w Server Actions
Rzucony wyjątek to dla użytkownika ekran błędu. Lepiej zwracać ustrukturyzowany wynik z polem success, komunikatem error i błędami pól. Walidację zrobi Zod, biblioteka opisująca kształt danych schematem.
Wzorzec z obsługą błędów
Schemat loginSchema sprawdza email i hasło, interfejs ActionResult opisuje wynik, a parametr prevState jest wymagany przez hook z następnej sekcji:
1// actions/auth.ts
2'use server';
3
4import { z } from 'zod';
5
6const loginSchema = z.object({
7 email: z.string().email('Nieprawidłowy email'),
8 password: z.string().min(8, 'Hasło musi mieć min. 8 znaków'),
9});
10
11interface ActionResult {
12 success: boolean;
13 error?: string;
14 fieldErrors?: Record<string, string[]>;
15}
16
17export async function loginAction(
18 prevState: ActionResult,
19 formData: FormData
20): Promise<ActionResult> {
21 const rawData = {
22 email: formData.get('email'),
23 password: formData.get('password'),
24 };
25
26 // Walidacja danych
27 const validatedFields = loginSchema.safeParse(rawData);
28
29 if (!validatedFields.success) {
30 return {
31 success: false,
32 fieldErrors: validatedFields.error.flatten().fieldErrors,
33 };
34 }
35
36 try {
37 const user = await authenticateUser(
38 validatedFields.data.email,
39 validatedFields.data.password
40 );
41
42 if (!user) {
43 return { success: false, error: 'Nieprawidłowe dane logowania' };
44 }
45
46 // Utwórz sesję
47 await createSession(user.id);
48 return { success: true };
49 } catch (error) {
50 return { success: false, error: 'Wystąpił błąd serwera' };
51 }
52}safeParse nie rzuca wyjątku, więc błędy walidacji wracają jako fieldErrors, a błędy serwera try/catch zamienia na ogólny komunikat. W Zod 4 zapiszesz to nowszym API: z.email() i z.flattenError(error).
Komponent formularza z useActionState
Hook useActionState z React 19 przyjmuje akcję i stan początkowy, a zwraca stan, akcję dla form i flagę isPending:
1// app/login/page.tsx
2'use client';
3
4import { useActionState } from 'react';
5import { loginAction } from './actions';
6
7export default function LoginForm() {
8 const [state, formAction, isPending] = useActionState(loginAction, {
9 success: false,
10 });
11
12 return (
13 <form action={formAction}>
14 <div>
15 <input name="email" type="email" placeholder="Email" />
16 {state.fieldErrors?.email && (
17 <p className="text-red-500">{state.fieldErrors.email[0]}</p>
18 )}
19 </div>
20
21 <div>
22 <input name="password" type="password" placeholder="Hasło" />
23 {state.fieldErrors?.password && (
24 <p className="text-red-500">{state.fieldErrors.password[0]}</p>
25 )}
26 </div>
27
28 {state.error && (
29 <div className="bg-red-100 text-red-700 p-3 rounded">
30 {state.error}
31 </div>
32 )}
33
34 <button type="submit" disabled={isPending}>
35 {isPending ? 'Logowanie...' : 'Zaloguj się'}
36 </button>
37 </form>
38 );
39}Błędy pojawiają się pod polami, a przycisk blokuje się na czas wysyłki, więc podwójnego kliknięcia już nie będzie. Akcja się nie zmieniła: React przekazuje jej poprzedni stan i dane formularza.
Progressive Enhancement
Server Actions działają nawet bez JavaScriptu. Formularz HTML domyślnie wysyła dane metodą POST, więc przepływ działa, zanim przeglądarka uruchomi skrypty.
Formularz z progressive enhancement
Ten formularz to zwykły Server Component, bez 'use client' i bez żadnego hooka:
1// app/contact/page.tsx
2import { sendMessage } from './actions';
3
4// Ten formularz działa nawet z wyłączonym JavaScript!
5export default function ContactForm() {
6 return (
7 <form action={sendMessage}>
8 <input name="name" required placeholder="Imię" />
9 <input name="email" type="email" required placeholder="Email" />
10 <textarea name="message" required placeholder="Wiadomość" />
11 <button type="submit">Wyślij</button>
12 </form>
13 );
14}Bez JavaScriptu przeglądarka wyśle go klasycznie, a po hydracji Next.js zrobi to bez przeładowania. Akcja zapisuje wiadomość i przekierowuje:
1// app/contact/actions.ts
2'use server';
3
4import { redirect } from 'next/navigation';
5
6export async function sendMessage(formData: FormData) {
7 const name = formData.get('name') as string;
8 const email = formData.get('email') as string;
9 const message = formData.get('message') as string;
10
11 await db.messages.create({
12 data: { name, email, message },
13 });
14
15 redirect('/contact/success');
16}redirect działa przez rzucenie specjalnego wyjątku, więc wołaj go poza blokiem try/catch, inaczej go połkniesz.
Kompozycja Server Actions
W jednej akcji możesz połączyć kilka kroków, a każdy może zakończyć ją wcześniej, zwracając błąd:
1// actions/checkout.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5import { redirect } from 'next/navigation';
6
7export async function processCheckout(formData: FormData) {
8 const cartId = formData.get('cartId') as string;
9
10 // 1. Walidacja koszyka
11 const cart = await validateCart(cartId);
12 if (!cart.valid) {
13 return { error: 'Koszyk jest nieprawidłowy' };
14 }
15
16 // 2. Przetworzenie płatności
17 const payment = await processPayment({
18 amount: cart.total,
19 method: formData.get('paymentMethod') as string,
20 });
21
22 if (!payment.success) {
23 return { error: 'Płatność nie powiodła się' };
24 }
25
26 // 3. Utworzenie zamówienia
27 const order = await createOrder({
28 cartId,
29 paymentId: payment.id,
30 shippingAddress: formData.get('address') as string,
31 });
32
33 // 4. Odśwież dane
34 revalidatePath('/orders');
35 revalidatePath('/cart');
36
37 // 5. Przekieruj do potwierdzenia
38 redirect('/orders/' + order.id + '/confirmation');
39}Kolejność ma znaczenie: walidacja, płatność, zamówienie, rewalidacja, przekierowanie. Każda Server Action to publiczny endpoint POST, więc sesję i uprawnienia sprawdzaj w środku akcji, nie tylko w UI.
Upload plików z Server Actions
Pole typu file trafia do FormData jako obiekt File. Akcja sprawdza jego obecność, rozmiar i typ, a potem zapisuje go na dysku:
1// actions/upload.ts
2'use server';
3
4import { writeFile } from 'fs/promises';
5import path from 'path';
6
7export async function uploadFile(prevState: unknown, formData: FormData) {
8 const file = formData.get('file') as File;
9
10 if (!file || file.size === 0) {
11 return { error: 'Nie wybrano pliku' };
12 }
13
14 // Walidacja typu i rozmiaru
15 const maxSize = 5 * 1024 * 1024; // 5MB
16 if (file.size > maxSize) {
17 return { error: 'Plik jest za duży (max 5MB)' };
18 }
19
20 const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
21 if (!allowedTypes.includes(file.type)) {
22 return { error: 'Niedozwolony typ pliku' };
23 }
24
25 // Zapis pliku
26 const bytes = await file.arrayBuffer();
27 const buffer = Buffer.from(bytes);
28 const filename = Date.now() + '-' + file.name;
29 const filepath = path.join(process.cwd(), 'public/uploads', filename);
30
31 await writeFile(filepath, buffer);
32
33 return { success: true, url: '/uploads/' + filename };
34}Parametr prevState jest potrzebny, bo useActionState zawsze przekazuje poprzedni stan jako pierwszy argument. Domyślny limit ciała żądania Server Action to 1 MB, więc dla plików do 5 MB podnieś experimental.serverActions.bodySizeLimit w next.config.js.
Komponent kliencki jest krótki, bo stanem zarządza hook:
1// components/FileUpload.tsx
2'use client';
3
4import { useActionState } from 'react';
5import { uploadFile } from '@/actions/upload';
6
7export default function FileUpload() {
8 const [state, formAction, isPending] = useActionState(uploadFile, null);
9
10 return (
11 <form action={formAction}>
12 <input type="file" name="file" accept="image/*" />
13 <button type="submit" disabled={isPending}>
14 {isPending ? 'Przesyłanie...' : 'Wyślij plik'}
15 </button>
16
17 {state?.error && <p className="text-red-500">{state.error}</p>}
18 {state?.success && (
19 <img src={state.url} alt="Przesłany plik" className="mt-4 max-w-xs" />
20 )}
21 </form>
22 );
23}Po udanej wysyłce widać podgląd obrazu, a przy błędzie komunikat z akcji. Żadnego ręcznego fetch, komunikację załatwia Next.js.
Podsumowanie
Server Actions to potężny mechanizm Next.js, który upraszcza komunikację klient-serwer:
- useOptimistic - natychmiastowa aktualizacja UI przed odpowiedzią serwera
- revalidatePath / revalidateTag - precyzyjna rewalidacja danych po mutacji
- Obsługa błędów - ustrukturyzowane odpowiedzi z walidacją Zod
- Progressive enhancement - formularze działają bez JavaScript
- Kompozycja - łączenie wielu operacji w jeden flow
- Upload plików - natywna obsługa przesyłania plików
Moja rada: zwracaj z akcji obiekt z polem success zamiast rzucać wyjątki, a useOptimistic stosuj tam, gdzie operacja prawie zawsze się udaje. W następnej lekcji zbudujesz tabele z TanStack Table, a do tagów wrócisz przy use cache.
Zapamiętaj: w Metropolii Quantum dobra Server Action waliduje, zapisuje, rewaliduje i zawsze mówi interfejsowi, jak poszło.
Kod do tej lekcji: App.tsx
1import React, { useState, useCallback } from 'react';
2
3interface Task {
4 id: number;
5 title: string;
6 completed: boolean;
7 pending?: boolean;
8}
9
10interface ActionResult {
11 success: boolean;
12 error?: string;
13}
14
15// Simulate async server action
16const simulateServerAction = (ms: number): Promise<ActionResult> =>
17 new Promise(resolve => setTimeout(() => resolve({ success: true }), ms));
18
19const ServerActionsDemo = () => {
20 const [tasks, setTasks] = useState<Task[]>([
21 { id: 1, title: 'Skonfiguruj revalidatePath', completed: true },
22 { id: 2, title: 'Dodaj useOptimistic', completed: false },
23 { id: 3, title: 'Obsluz bledy w Server Action', completed: false },
24 { id: 4, title: 'Zaimplementuj upload plikow', completed: false },
25 ]);
26 const [newTask, setNewTask] = useState('');
27 const [actionLog, setActionLog] = useState<string[]>([]);
28 const [uploading, setUploading] = useState(false);
29 const [uploadResult, setUploadResult] = useState<string | null>(null);
30 const [formError, setFormError] = useState<string | null>(null);
31
32 const addLog = (msg: string) => setActionLog(prev => [...prev.slice(-8), msg]);
33
34 const addTask = async () => {
35 if (!newTask.trim()) return;
36 const tempId = Date.now();
37 // Optimistic update
38 setTasks(prev => [...prev, { id: tempId, title: newTask, completed: false, pending: true }]);
39 addLog('useOptimistic: UI updated instantly');
40 setNewTask('');
41
42 // Simulate server action
43 await simulateServerAction(800);
44 setTasks(prev => prev.map(t => t.id === tempId ? { ...t, pending: false } : t));
45 addLog('Server Action: Task saved, revalidatePath called');
46 };
47
48 const toggleTask = async (id: number) => {
49 // Optimistic toggle
50 setTasks(prev => prev.map(t => t.id === id ? { ...t, completed: !t.completed, pending: true } : t));
51 addLog('useOptimistic: Toggle applied instantly');
52
53 await simulateServerAction(500);
54 setTasks(prev => prev.map(t => t.id === id ? { ...t, pending: false } : t));
55 addLog('Server: revalidateTag("tasks") called');
56 };
57
58 const deleteTask = async (id: number) => {
59 setTasks(prev => prev.filter(t => t.id !== id));
60 addLog('Optimistic: Task removed from UI');
61 await simulateServerAction(400);
62 addLog('Server: Task deleted, cache revalidated');
63 };
64
65 const simulateUpload = async () => {
66 setUploading(true);
67 setUploadResult(null);
68 addLog('Server Action: Processing file upload...');
69 await simulateServerAction(1200);
70 setUploadResult('/uploads/quantum-report-2150.pdf');
71 setUploading(false);
72 addLog('Server: File saved to /uploads/');
73 };
74
75 const simulateFormError = async () => {
76 setFormError(null);
77 addLog('Server Action: Validating form with Zod...');
78 await simulateServerAction(600);
79 setFormError('Email jest wymagany. Haslo musi miec min. 8 znakow.');
80 addLog('Server: Validation failed, returning fieldErrors');
81 };
82
83 return (
84 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: '20px', color: '#fff', fontFamily: 'sans-serif' }}>
85 <h1 style={{ color: '#64ffda', textAlign: 'center' }}>Server Actions - Zaawansowane wzorce</h1>
86 <p style={{ textAlign: 'center', color: '#888', marginBottom: '20px' }}>useOptimistic + revalidation + error handling + file upload</p>
87
88 <div style={{ maxWidth: '800px', margin: '0 auto', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
89 <div>
90 <h3 style={{ color: '#7c4dff', marginBottom: '10px' }}>Optimistic Updates</h3>
91 <div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
92 <input value={newTask} onChange={e => setNewTask(e.target.value)} placeholder="Nowe zadanie..."
93 onKeyDown={e => e.key === 'Enter' && addTask()}
94 style={{ flex: 1, padding: '8px', background: '#16213e', border: '1px solid #444', borderRadius: '6px', color: '#fff' }} />
95 <button onClick={addTask} style={{ padding: '8px 16px', background: '#7c4dff', border: 'none', borderRadius: '6px', color: '#fff', cursor: 'pointer' }}>Add</button>
96 </div>
97
98 <div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: '8px', overflow: 'hidden' }}>
99 {tasks.map(task => (
100 <div key={task.id} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 12px', borderBottom: '1px solid #1a1a2e', opacity: task.pending ? 0.6 : 1 }}>
101 <input type="checkbox" checked={task.completed} onChange={() => toggleTask(task.id)} />
102 <span style={{ flex: 1, textDecoration: task.completed ? 'line-through' : 'none', color: task.completed ? '#555' : '#fff' }}>
103 {task.title}
104 {task.pending && <span style={{ color: '#ff9800', fontSize: '11px', marginLeft: '6px' }}>(syncing...)</span>}
105 </span>
106 <button onClick={() => deleteTask(task.id)} style={{ background: 'none', border: 'none', color: '#f44336', cursor: 'pointer' }}>x</button>
107 </div>
108 ))}
109 </div>
110
111 <h3 style={{ color: '#7c4dff', margin: '16px 0 10px' }}>Error Handling</h3>
112 <button onClick={simulateFormError} style={{ padding: '8px 16px', background: '#f44336', border: 'none', borderRadius: '6px', color: '#fff', cursor: 'pointer', marginBottom: '8px' }}>
113 Simulate Form Validation Error
114 </button>
115 {formError && (
116 <div style={{ background: 'rgba(244,67,54,0.15)', border: '1px solid #f44336', padding: '10px', borderRadius: '6px', color: '#f44336', fontSize: '13px' }}>
117 {formError}
118 </div>
119 )}
120
121 <h3 style={{ color: '#7c4dff', margin: '16px 0 10px' }}>File Upload</h3>
122 <button onClick={simulateUpload} disabled={uploading} style={{ padding: '8px 16px', background: uploading ? '#555' : '#4caf50', border: 'none', borderRadius: '6px', color: '#fff', cursor: uploading ? 'default' : 'pointer' }}>
123 {uploading ? 'Uploading...' : 'Upload File'}
124 </button>
125 {uploadResult && <p style={{ color: '#4caf50', fontSize: '13px', marginTop: '6px' }}>Saved: {uploadResult}</p>}
126 </div>
127
128 <div>
129 <h3 style={{ color: '#7c4dff', marginBottom: '10px' }}>Action Log</h3>
130 <div style={{ background: '#000', padding: '12px', borderRadius: '8px', minHeight: '300px', maxHeight: '500px', overflowY: 'auto', fontFamily: 'monospace', fontSize: '11px' }}>
131 {actionLog.length === 0 ? (
132 <span style={{ color: '#555' }}>Interact with the demo to see Server Action logs...</span>
133 ) : actionLog.map((log, i) => (
134 <div key={i} style={{ color: log.includes('Server') ? '#4caf50' : '#64ffda', padding: '3px 0' }}>
135 {log.includes('Server') ? '>> ' : '-> '}{log}
136 </div>
137 ))}
138 </div>
139 </div>
140 </div>
141 </div>
142 );
143};
144
145export default ServerActionsDemo;Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jaki hook React pozwala na natychmiastowe aktualizowanie UI przed odpowiedzią serwera w Server Actions?
2. Która funkcja Next.js pozwala odświeżyć dane na podstawie tagu cache po wykonaniu Server Action?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Napisz Server Action która waliduje dane formularza i zwraca ustrukturyzowany wynik z polem success i opcjonalnym error.
- Klikanie w kolejności
Uporządkuj implementację server-side operations.