Formulare in Next.js als Vertrag: Zod, fieldErrors und einheitliche Regeln auf Client und Server
Ein Formular ist nicht nur eine Sammlung von Feldern und Buttons – es ist ein Vertrag zwischen UI, Validierung und Datenquelle. Wenn dieser Vertrag nicht explizit definiert ist, bricht Chaos aus: Die UI hält die Daten für gültig, der Server wirft einen Fehler, Nachrichten tauchen zufällig auf, und das Verhalten des Formulars ist unvorhersehbar. Die Lösung? Vereinheitliche das Validierungsschema, das Fehlerformat und die Zustände mit Zod, safeParse und flatten, um identische Regeln sowohl auf dem Client als auch auf dem Server zu gewährleisten.
Warum Formulare ohne Vertrag auseinanderfallen
Viele Projekte starten einfach: Input, State, Submit. Das funktioniert prima für kleine Formulare. Aber wenn der Codebase wächst, tauchen drei kritische Abweichungspunkte auf:
- Inkonsistente Validierungsregeln. Der Client prüft die String-Länge, der Server aber auch verbotene Wörter oder Leerzeichen. Nutzer sehen ein grünes Häkchen, doch der Submit schlägt fehl.
- Nicht passende Fehlerformate. Mal ein String, dann ein Objekt, danach ein Array. Die UI muss die Struktur der Response erraten.
- Nicht definierte Zustände. Pending, disabled, success, formError – jedes Component geht anders damit um, was zu einer zersplitterten UX führt.
Ohne einen einzigen Vertrag folgt jedes Formular seinen eigenen lokalen Regeln. Das skaliert nicht.
Das Formular als Vertrag: Wichtige Elemente
Ein Formularvertrag muss diese Fragen eindeutig beantworten:
- Welche Daten gelten als gültig?
- Welche Fehler betreffen spezifische Felder (fieldErrors), und welche die Gesamtoperation (formError)?
- Wie wird der Pending-Zustand gehandhabt?
- Was wird bei Erfolg zurückgegeben?
- Kann der Server eine Antwort in einem anderen Format liefern?
- Werden dieselben Regeln auf Client und Server angewendet?
Diese Antworten sollten feststehen, bevor die UI implementiert wird. In Workbench hat dieser Ansatz Formulare um Zod-Schemas und eine einheitliche Response-Struktur standardisiert.
Ein Zod-Schema, zwei Einsatzmöglichkeiten
Statt die Validierungslogik zwischen Client und Server zu duplizieren, nutzt du überall ein einziges Zod-Schema. Beispielschema für eine Notiz:
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>;
Dieses Schema definiert gültige Werte, Transformationen (wie trim) und benutzerdefinierte Regeln (refine). Es wird zur einzigen Quelle der Wahrheit für die Validierung.
Sichere Validierung mit safeParse
Die Verwendung von safeParse statt parse ist für Formulare entscheidend. parse wirft bei Fehlern eine Exception – schlecht für die UX, wo ungültige Eingaben erwartet werden. safeParse liefert ein handhabbares Ergebnis:
export function validateNote(input: unknown) {
return noteSchema.safeParse(input);
}
Das Ergebnis enthält eine success-Flag und bei Bedarf ein error-Objekt. So können UI- und Serverlogik denselben Response-Typ ohne try/catch handhaben.
Fehler vereinheitlichen mit flatten
Zod liefert ein komplexes Fehlerobjekt. Für Formulare reichen zwei Ebenen: Feldfehler und formularweiter Fehler. Die flatten()-Methode ist ideal:
export function formatZodError(error: import("zod").ZodError) {
const flat = error.flatten();
return {
fieldErrors: flat.fieldErrors,
formError: flat.formErrors[0] ?? null,
};
}
Beispiele für Unterschiede:
- fieldErrors: „Titel darf nicht leer sein“, „Beschreibung überschreitet 500 Zeichen“.
- formError: „Notiz mit diesem Slug existiert bereits“, „Server vorübergehend nicht verfügbar“.
Diese Trennung macht die UX vorhersehbar: Nutzer wissen genau, wo sie eingreifen müssen und wann sie neu versuchen oder warten sollen.
Einheitlicher Vertrag auf Client und Server
Auf dem Client ermöglicht das Zod-Schema frühe Validierung und UI-Zustandsverwaltung:
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;
Auf dem Server dient dasselbe Schema als letzte Verteidigungslinie:
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 {
// save to database
return {
ok: true,
fieldErrors: {},
formError: null,
};
} catch {
return {
ok: false,
fieldErrors: {},
formError: "Not succeeded sokhranit zametku",
};
}
}
Eine Regel, verschiedene Rollen: UX auf dem Client, Sicherheit auf dem Server.
Standard-FormState-Struktur
Um Fragmentierung zu vermeiden, definiere einen einheitlichen Typ für alle Formulare:
export type FormState = {
ok: boolean;
fieldErrors: Record<string, string[] | undefined>;
formError: string | null;
};
Diese Struktur deckt alle Zustände ab: Erfolg, Felderfehler, allgemeine Fehler. Jedes Formular im Projekt liefert dieselbe Struktur – das vereinfacht die UI-Verarbeitung.
Wann React Hook Form einsetzen
React Hook Form (RHF) ist mächtig, aber kein Allheilmittel. Es lohnt sich bei Formularen mit:
- Vielen Feldern mit abhängiger Validierung
- Dynamischen Listen oder verschachtelten Strukturen
- Blur-Validierung, touched/dirty-Zuständen
- Benutzerdefinierten Controls mit Fokusverwaltung
- Bedarf an Render-Optimierung
Bei einfachen Formularen mit 1–2 Feldern und Basisvalidierung bringt RHF unnötige Komplexität. Wenn useMemo + Zod abgeleiteter State reicht – bleib einfach.
Denkweise wechseln: Vom UI zum Vertrag
Der entscheidende Wechsel? Hör auf, Formulare als reine UI-Components zu sehen. Ein Formular ist ein Vertrag, der definiert:
- Gültiges Datenschema
- Fehler-Response-Format
- Verhalten für Zustände (pending, success, error)
- Verantwortlichkeitsgrenzen zwischen Client und Server
Mit dem Vertrag im Voraus wird die UI-Implementierung mechanisch. Am wichtigsten: Jedes Formular im Projekt verhält sich vorhersehbar, weil es denselben Vertrag einhält.
Wichtige Erkenntnisse
- Verwende ein Zod-Schema für Client und Server – eliminiert Regelabweichungen.
- Nutze immer
safeParse– es liefert handhabbare Ergebnisse statt Exceptions. - Trenne
fieldErrorsundformError– verbessert UX und vereinfacht Anzeigelogik. - Definiere eine standard
FormState-Struktur – alle Formulare liefern dieselbe Struktur. - Füge React Hook Form nur hinzu, wenn es wirklich nötig ist – nicht aus Trend, sondern für komplexe Szenarien.
— Editorial Team
Noch keine Kommentare.