next.jsreact

Server Actions w Next.js: formularz, który działa, zanim JavaScript się załaduje

Server Actions w Next.js: formularz, który działa, zanim JavaScript się załaduje

Klasyczny wzorzec formularza w Reakcie: onSubmit, event.preventDefault(), fetch('/api/todos', { method: 'POST', body: JSON.stringify(...) }), useState do obsługi stanu ładowania. Działa – pod jednym warunkiem, o którym łatwo zapomnieć, bo na własnym laptopie z szybkim łączem nigdy się nie ujawnia: JavaScript musi zdążyć się załadować, sparsować i wykonać, zanim formularz w ogóle zacznie reagować na kliknięcie. Na wolnym połączeniu mobilnym, w trakcie hydratacji dużej strony, albo gdy skrypt zostanie zablokowany przez rozszerzenie przeglądarki – użytkownik klika „Wyślij”, i dosłownie nic się nie dzieje. Nie ma błędu, nie ma spinnera – onClick po prostu jeszcze nie istnieje.

Server Actions w Next.jsie bywają przedstawiane jako wygodniejsza składnia na dokładnie to samo wywołanie – „fetch, tylko że nie musisz pisać API route”. To spłaszczenie, które gubi to, co w tym mechanizmie faktycznie nowe: <form action={mojaAkcja}> opiera się na natywnym, istniejącym od zawsze mechanizmie przesyłania formularzy przez przeglądarkę, nie na JavaScripcie jako warunku koniecznym. To zmienia zarówno to, jak to działa pod spodem, jak i to, jakie błędy w praktyce popełniają zespoły, które traktują "use server" jak zwykłe wywołanie lokalnej funkcji.

Co naprawdę robi dyrektywa "use server"

Kiedy oznaczysz funkcję "use server", kompilator Next.jsa nie zostawia jej ciała w paczce, którą dostaje przeglądarka. Wycina je i zamienia na referencję – zaszyfrowany identyfikator wskazujący, którą funkcję na serwerze wywołać. To, co faktycznie trafia do klienta, to mały stub: „gdy mnie wywołasz, wyślij POST-a na specjalny endpoint RSC z tym identyfikatorem i zserializowanymi argumentami”. Sama logika – zapytanie do bazy, walidacja, cokolwiek robi funkcja – nigdy fizycznie nie opuszcza serwera.

tsx

// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function addTodoAction(prevState: unknown, formData: FormData) {
const title = formData.get("title");
if (typeof title !== "string" || title.trim().length === 0) {
return { error: "Tytuł nie może być pusty." };
}
await db.todo.create({ data: { title: title.trim() } });
revalidatePath("/todos");
return { error: null };
}

Kiedy przypinasz tę funkcję do <form action={addTodoAction}>, dzieje się coś, czego zwykły fetch nigdy nie dawał za darmo: działa to zanim jakikolwiek JavaScript się wykona. Przeglądarka od zawsze potrafi wysłać formularz jako natywny POST, jeśli atrybut action wskazuje URL – React 19 i Next.js rozszerzają to tak, że action może też być funkcją serwerową, a przeglądarka i tak wykona swoją natywną ścieżkę, jeśli JS jeszcze nie jest gotowy. Gdy JavaScript już działa, Next.js przechwytuje to zdarzenie i robi to samo przez fetch w tle, bez przeładowania strony, z płynną aktualizacją UI. To jest progressive enhancement wbudowany w framework, nie coś, co musisz sam zaprogramować – dokładnie ten sam mechanizm, o którym pisaliśmy przy okazji RouteAnnouncer w artykule o dostępności, tylko zastosowany do mutacji danych zamiast nawigacji.

Praktyczny przykład: formularz z pełną obsługą stanu, bez ręcznego fetcha

React 19 dodał useActionState specjalnie pod ten wzorzec – łączy wywołanie akcji serwerowej ze stanem zwróconym przez ostatnie jej wykonanie, bez ręcznego useState do przechowywania błędu czy statusu ładowania.

tsx

"use client";
import { useActionState } from "react";
import { addTodoAction } from "./actions";
const initialState = { error: null };
const TodoForm = () => {
const [state, formAction, isPending] = useActionState(
addTodoAction,
initialState,
);
return (
<form action={formAction}>
<input type="text" name="title" placeholder="Nowe zadanie" required />
<button type="submit" disabled={isPending}>
{isPending ? "Dodawanie…" : "Dodaj"}
</button>
{state.error && <p className="form-error">{state.error}</p>}
</form>
);
};
export default TodoForm;

Kilka rzeczy dzieje się tu jednocześnie, bez linijki kodu poświęconej ręcznemu zarządzaniu requestem. isPending odzwierciedla realny stan przesyłania formularza – łącznie z tym natywnym, sprzed-hydratacyjnym POST-em, nie tylko wywołania po stronie klienta. state.error to bezpośrednio to, co zwróciła funkcja serwerowa – żadnego parsowania odpowiedzi JSON, żadnego try/catch wokół fetch. revalidatePath("/todos") w akcji każe Next.jsowi odświeżyć cache dla tej trasy, więc lista zadań na stronie pokaże nowy wpis bez ręcznego odświeżania stanu klienta – to framework, a nie Ty, decyduje, kiedy i jak przeciągnąć nowy RSC payload.

Pułapki i dobre praktyki

  • Każda funkcja "use server" to publiczny endpoint HTTP – nie prywatna funkcja. To najczęstsza pomyłka bezpieczeństwa przy Server Actions: skoro nic w UI jej wprost nie linkuje, wydaje się „ukryta”. Nie jest – kompilator generuje dla niej trwały, wywoływalny z zewnątrz identyfikator akcji, więc każdy, kto go pozna (np. podglądając ruch sieciowy w DevTools), może wywołać ją bezpośrednio, z pominięciem Twojego UI i jego założeń. Walidację danych wejściowych i sprawdzenie uprawnień (sesja, rola użytkownika) trzeba wykonać wewnątrz samej akcji, dokładnie tak, jak w klasycznym endpoincie API – nigdy nie polegaj na tym, że „przycisk jest niewidoczny dla niezalogowanych”.
  • Argumenty i wartość zwracana muszą być serializowalne. Server Actions przesyłają dane przez sieć w formacie serializacji Reacta (bogatszym niż JSON – obsługuje m.in. Date, Map, FormData przekazane bezpośrednio z formularza), ale wciąż nie przepuszczą funkcji, instancji klas czy referencji do obiektów DOM. Jeśli używasz .bind(), żeby domknąć dodatkowy argument przy wywołaniu z klienta (addTodoAction.bind(null, listId)), pamiętaj, że listId też musi dać się zserializować.
  • To wciąż realny request sieciowy, nie lokalne wywołanie funkcji. Łatwo o tym zapomnieć, bo składniowo await mojaAkcja(dane) wygląda identycznie jak wywołanie zwykłej funkcji JS. Pod spodem to zawsze pełny round-trip: serializacja, HTTP, deserializacja po drugiej stronie. Wywoływanie Server Action w pętli po każdym znaku wpisywanym w polu tekstowym (np. do walidacji na bieżąco) zamieni Twój formularz w generator requestów, nie w responsywny input – do tego typu przypadków debounce jest równie konieczny, jak przy każdym innym wywołaniu sieciowym.
  • Brak revalidatePath/revalidateTag zostawia UI z nieaktualnym cache. Next.js domyślnie cache'uje dane stron – akcja, która zapisuje coś w bazie, ale nie mówi frameworkowi, którą trasę odświeżyć, skutkuje częstym zgłoszeniem „zapisało się, ale nie widać” – dane są poprawne w bazie, tylko UI wciąż pokazuje starą, zbuforowaną wersję strony.

Podsumowanie

Server Actions nie są „fetchem z ładniejszą składnią” – to dwie różne rzeczy zbudowane na tym samym mechanizmie. Po pierwsze, opierają się na natywnym przesyłaniu formularzy przez przeglądarkę, więc formularz działa nawet zanim JavaScript zdąży się załadować – Ty nie musisz pisać ani linijki kodu odpowiedzialnej za to zachowanie, dostajesz je z samej architektury <form action={...}>. Po drugie, i ważniejsze z punktu widzenia bezpieczeństwa: każda funkcja oznaczona "use server" staje się realnym, wywoływalnym z zewnątrz punktem końcowym w sieci, niezależnie od tego, czy jakikolwiek widoczny element UI ją wywołuje. Traktowanie jej jak lokalnej, zaufanej funkcji – bez walidacji i autoryzacji w środku – to najprostszy sposób, żeby wygodna składnia zamieniła się w niezabezpieczony endpoint API.