# Formuláře v Next.js jako kontrakt: Zod, fieldErrors a jednotná pravidla na klientovi a serveru
Formulář není jen sada polí a tlačítek, ale smlouva mezi rozhraním, validací a zdrojem dat. Když není tato smlouva explicitně definována, vzniká chaos: UI považuje data za správná, server vrací chybu, zprávy se zobrazují kdekoliv a chování formuláře je nepředvídatelné. Řešení – sjednotit schéma validace, formát chyb a stavy pomocí Zod, safeParse a flatten, čímž zajistíte stejná pravidla na klientovi i serveru.
Proč se formuláře rozpadají bez kontraktu
Mnoho projektů začíná jednoduše: input, state, submit. Dokud je formulář malý – vše funguje. Ale s růstem kódu se objeví tři kritické body nesouladu:
- Různá pravidla validace. Klient kontroluje délku řetězce, server navíc zakázaná slova nebo mezery. Uživatel vidí zelený háček, ale uložení selže.
- Nekonzistentní formát chyb. Jednou přijde řetězec, jindy objekt, potřetí pole. UI musí hádat strukturu odpovědi.
- Neurčitá stavy. Pending, disable, success, formError – každý komponent je realizuje po svém, což vede k fragmentovanému UX.
Bez jednotné smlouvy žije každý formulář podle svých lokálních pravidel. To se nedaří škálovat.
Formulář jako kontrakt: klíčové prvky
Smlouva formuláře musí jasně odpovídat na tyto otázky:
- Jaká data se považují za platná?
- Které chyby se týkají konkrétních polí (fieldErrors), a které celé operace (formError)?
- Jak se zpracovává stav pending?
- Co se vrací při úspěchu?
- Může server vrátit odpověď v jiném formátu?
- Používají se stejná pravidla na klientovi i serveru?
Odpovědi na tyto otázky musí být stanoveny před zahájením implementace UI. V Workbench tento přístup umožnil standardizovat formuláře kolem schémat Zod a jednotného tvaru odpovědi.
Jedno schéma Zod – dvě použití
Místo duplikování logiky validace na klientovi a serveru použijte jedno schéma Zod, aplikované na obou místech. Příklad schématu pro poznámku:
import { z } from "zod";
export const noteSchema = z.object({
title: z
.string()
.trim()
.min(3, "Enter minimum 3 simvola")
.max(80, "Maximum 80 characters"),
description: z
.string()
.trim()
.max(500, "Maximum 500 characters")
.optional()
.or(z.literal("")),
}).refine(
data => data.title.toLowerCase() !== "test",
{
path: ["title"],
message: "Withlishkom tekhnicheskoe name for zametki",
}
);
export type NoteInput = z.infer<typeof noteSchema>;
Toto schéma definuje platné hodnoty, transformace (např. trim) a vlastní pravidla (refine). Stává se jediným zdrojem pravdy pro validaci.
Bezpečná validace přes safeParse
Použití safeParse místo parse je pro formuláře klíčové. parse vyhazuje výjimku při chybě – to je špatné pro UX, kde je neplatný vstup očekávaný. safeParse vrací řiditelný výsledek:
export function validateNote(input: unknown) {
return noteSchema.safeParse(input);
}
Výsledek obsahuje příznak success a případně objekt error. To umožňuje UI i serverové logice pracovat se stejným typem odpovědi bez try/catch.
Sjednocení chyb přes flatten
Zod vrací složitý objekt chyby. Pro formuláře stačí dvě úrovně: chyby polí a obecná chyba formuláře. Metoda flatten() je ideální:
export function formatZodError(error: import("zod").ZodError) {
const flat = error.flatten();
return {
fieldErrors: flat.fieldErrors,
formError: flat.formErrors[0] ?? null,
};
}
Příklad rozdílu:
- fieldErrors: „Pole title nemůže být prázdné“, „Popis překračuje 500 znaků“.
- formError: „Poznámka s tímto slugem již existuje“, „Server je dočasně nedostupný“.
Toto rozdělení činí UX předvídatelným: uživatel přesně ví, kde opravit vstup a kde opakovat akci nebo počkat.
Jednotná smlouva na klientovi a serveru
Na klientovi se schéma Zod používá pro časnou validaci a řízení stavu 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 serveru slouží stejné schéma jako poslední linie obrany:
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 {
// zápis do databáze
return {
ok: true,
fieldErrors: {},
formError: null,
};
} catch {
return {
ok: false,
fieldErrors: {},
formError: "Not succeeded sokhranit zametku",
};
}
}
Pravidlo je jedno – role různé: UX na klientovi, bezpečnost na serveru.
Standardní tvar stavu formuláře
Aby se zabránilo fragmentaci, definujte jednotný typ pro všechny formuláře:
export type FormState = {
ok: boolean;
fieldErrors: Record<string, string[] | undefined>;
formError: string | null;
};
Tento tvar pokrývá všechny možné stavy: úspěšné provedení, chyby polí, obecné chyby. Všechny formuláře v projektu vracejí stejnou strukturu – to zjednodušuje zpracování na úrovni UI.
Kdy je potřeba React Hook Form
React Hook Form (RHF) je silný nástroj, ale ne univerzální řešení. Je oprávněný, pokud formulář obsahuje:
- Mnoho polí se závislou validací
- Dynamické seznamy nebo vnořené struktury
- Validaci na blur, stavy touched/dirty
- Vlastní kontroly s řízením fokusu
- Potřebu optimalizace re-renderů
Pro jednoduché formuláře s 1–2 poli a základní validací přidává RHF zbytečnou složitost. Pokud derived state přes useMemo + Zod řeší úkol – nezobtěžujte.
Změna myšlení: od UI ke kontraktu
Hlavní posun – přestat vnímat formulář jako komponent UI. Formulář je smlouva, která definuje:
- Schéma platných dat
- Formát vracení chyb
- Chování při různých stavech (pending, success, error)
- Hranice odpovědnosti klienta a serveru
Když je smlouva definována předem, implementace UI se stává mechanickou úlohou. A nejdůležitější – každý formulář v projektu se chová předvídatelně, protože dodržuje stejnou dohodu.
Co je důležité
- Používejte jedno schéma Zod pro klienta i server – to vylučuje nesoulady v pravidlech.
- Vždy aplikujte
safeParse– vrací řiditelný výsledek místo výjimek. - Rozdělujte
fieldErrorsaformError– to zlepšuje UX a zjednodušuje logiku zobrazení. - Definujte standardní tvar
FormState– všechny formuláře v projektu musí vracet stejnou strukturu. - Připojujte React Hook Form pouze při skutečné potřebě – ne kvůli módě, ale pro řešení složitých scénářů.
— Editorial Team
Zatím žádné komentáře.