Formy w Next.js jako kontrakt: Zod, fieldErrors i jednolite reguły na kliencie i serwerze
Formularz to nie zbiór pól i przycisków, lecz umowa między interfejsem, walidacją a źródłem danych. Gdy ta umowa nie jest wyraźnie określona, powstaje chaos: UI uważa dane za poprawne, serwer zwraca błąd, komunikaty wyświetlają się w losowych miejscach, a zachowanie formularza jest nieprzewidywalne. Rozwiązanie — ujednolicić schemat walidacji, format błędów i stany za pomocą Zod, safeParse i flatten, zapewniając identyczne reguły na kliencie i serwerze.
Dlaczego formularze rozpadają się bez kontraktu
Wiele projektów zaczyna od prostego: input, state, submit. Dopóki formularz jest mały — wszystko działa. Ale wraz z rozwojem kodu ujawniają się trzy krytyczne punkty rozbieżności:
- Różne reguły walidacji. Klient sprawdza długość ciągu, serwer — jeszcze zakazane słowa czy spacje. Użytkownik widzi zielony haczyk, ale zapisanie kończy się błędem.
- Niespójny format błędów. Raz przychodzi string, inny raz obiekt, kolejny — tablica. UI musi zgadywać strukturę odpowiedzi.
- Niezdefiniowane stany. Pending, disable, success, formError — każdy komponent implementuje je po swojemu, co prowadzi do rozdrobnionego UX.
Bez jednolitego kontraktu każdy formularz żyje według swoich lokalnych reguł. To nie skaluje się.
Formularz jako kontrakt: kluczowe elementy
Kontrakt formularza powinien jasno odpowiadać na następujące pytania:
- Jakie dane uważa się za dopuszczalne?
- Które błędy dotyczą konkretnych pól (fieldErrors), a które — całej operacji (formError)?
- Jak obsługuje się stan pending?
- Co zwraca się w przypadku sukcesu?
- Czy serwer może zwrócić odpowiedź w innym formacie?
- Czy stosuje się identyczne reguły na kliencie i serwerze?
Odpowiedzi na te pytania powinny być ustalone przed rozpoczęciem implementacji UI. W Workbench taki podejście pozwoliło na standaryzację formularzy wokół schematów Zod i jednolitego kształtu odpowiedzi.
Jeden schemat Zod — dwa zastosowania
Zamiast dublować logikę walidacji na kliencie i serwerze, użyj jednego schematu Zod, stosowanego w obu miejscach. Przykładowy schemat dla notatki:
import { z } from "zod";
export const noteSchema = z.object({
title: z
.string()
.trim()
.min(3, "Podaj minimum 3 znaki")
.max(80, "Maksymalnie 80 znaków"),
description: z
.string()
.trim()
.max(500, "Maksymalnie 500 znaków")
.optional()
.or(z.literal("")),
}).refine(
data => data.title.toLowerCase() !== "test",
{
path: ["title"],
message: "Zbyt techniczna nazwa dla notatki",
}
);
export type NoteInput = z.infer<typeof noteSchema>;
Ten schemat określa dopuszczalne wartości, transformacje (np. trim) i niestandardowe reguły (refine). Staje się jedynym źródłem prawdy dla walidacji.
Bezpieczna walidacja przez safeParse
Użycie safeParse zamiast parse jest kluczowe dla formularzy. parse rzuca wyjątek w przypadku błędu — to złe dla UX, gdzie niepoprawny input jest oczekiwany. safeParse zwraca kontrolowany wynik:
export function validateNote(input: unknown) {
return noteSchema.safeParse(input);
}
Wynik zawiera flagę success i, w razie potrzeby, obiekt error. Pozwala to UI i logice serwerowej pracować z tym samym typem odpowiedzi bez try/catch.
Ujednolicenie błędów przez flatten
Zod zwraca złożony obiekt błędu. Dla formularzy wystarczą dwa poziomy: błędy pól i ogólny błąd formularza. Metoda flatten() jest idealna:
export function formatZodError(error: import("zod").ZodError) {
const flat = error.flatten();
return {
fieldErrors: flat.fieldErrors,
formError: flat.formErrors[0] ?? null,
};
}
Przykład różnicy:
- fieldErrors: „Pole title nie może być puste”, „Opis przekracza 500 znaków”.
- formError: „Notatka o takim slug już istnieje”, „Serwer jest tymczasowo niedostępny”.
Takie rozdzielenie czyni UX przewidywalnym: użytkownik dokładnie wie, gdzie poprawić input, a gdzie powtórzyć czynność lub poczekać.
Jednolity kontrakt na kliencie i serwerze
Na kliencie schemat Zod służy do wczesnej walidacji i zarządzania stanem UI:
const clientResult = useMemo(() => {
return noteSchema.pick({ title: true }).safeParse({ title });
}, [title]);
const titleError = clientResult.success
? null
: clientResult.error.flatten().fieldErrors.title?.[0] ?? null;
const canSubmit = clientResult.success;
Na serwerze ten sam schemat jest ostatnią linią obrony:
export async function createNoteAction(raw: unknown) {
const parsed = noteSchema.safeParse(raw);
if (!parsed.success) {
const flat = parsed.error.flatten();
return {
ok: false,
fieldErrors: flat.fieldErrors,
formError: flat.formErrors[0] ?? null,
};
}
try {
// zapis do bazy
return {
ok: true,
fieldErrors: {},
formError: null,
};
} catch {
return {
ok: false,
fieldErrors: {},
formError: "Nie udało się zapisać notatki",
};
}
}
Reguła jedna — role różne: UX na kliencie, bezpieczeństwo na serwerze.
Standardowy kształt stanu formularza
Aby uniknąć rozdrobnienia, zdefiniuj jednolity typ dla wszystkich formularzy:
export type FormState = {
ok: boolean;
fieldErrors: Record<string, string[] | undefined>;
formError: string | null;
};
Ten kształt obejmuje wszystkie możliwe stany: udane wykonanie, błędy pól, ogólne błędy. Wszystkie formularze w projekcie zwracają identyczną strukturę — to upraszcza obsługę na poziomie UI.
Kiedy potrzebny jest React Hook Form
React Hook Form (RHF) to potężne narzędzie, ale nie uniwersalne rozwiązanie. Jest uzasadnione, jeśli formularz zawiera:
- Wiele pól z zależną walidacją
- Dynamiczne listy lub zagnieżdżone struktury
- Walidację na blur, stany touched/dirty
- Niestandardowe kontrolki z zarządzaniem fokusem
- Potrzebę optymalizacji rerenderów
Dla prostych formularzy z 1–2 polami i podstawową walidacją RHF dodaje zbędną złożoność. Jeśli derived state przez useMemo + Zod rozwiązuje zadanie — nie komplikuj.
Zmiana myślenia: od UI do kontraktu
Główna zmiana — przestać postrzegać formularz jako komponent UI. Formularz to kontrakt, który określa:
- Schemat dopuszczalnych danych
- Format zwracanych błędów
- Zachowanie w różnych stanach (pending, success, error)
- Granice odpowiedzialności klienta i serwera
Gdy kontrakt jest ustalony z góry, implementacja UI staje się mechaniczna. A co najważniejsze — każdy formularz w projekcie zachowuje się przewidywalnie, bo przestrzega tej samej konwencji.
Co ważne
- Używaj jednego schematu Zod dla klienta i serwera — eliminuje to rozbieżności w regułach.
- Zawsze stosuj
safeParse— zwraca kontrolowany wynik zamiast wyjątków. - Rozdzielaj
fieldErrorsiformError— poprawia to UX i upraszcza logikę wyświetlania. - Zdefiniuj standardowy kształt
FormState— wszystkie formularze w projekcie muszą zwracać identyczną strukturę. - Podłączaj React Hook Form tylko przy rzeczywistej potrzebie — nie dla mody, lecz do rozwiązywania złożonych scenariuszy.
— Editorial Team
Brak komentarzy.