Powrót do strony głównej

Formularze w Next.js jako kontrakt: Zod i jednolite reguły

Artykuł wyjaśnia, jak traktować formularze w Next.js nie jako komponenty UI, lecz jako kontrakty między interfejsem, walidacją a serwerem. Użycie Zod, safeParse i flatten pozwala ujednolicić reguły, format błędów i stany, zapewniając przewidywalny UX i brak rozbieżności między klientem a serwerem.

Jak przekształcić formularze w Next.js w niezawodny kontrakt za pomocą Zod
Advertisement 728x90

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ę.

Google AdInline article slot

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:

Google AdInline article slot
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.

Google AdInline article slot

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 fieldErrors i formError — 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

Advertisement 728x90

Czytaj dalej