Formulare und Validierung
Stack: react-hook-form + zod + @hookform/resolvers/zod + shadcn/ui
Form-Komponenten (bereits im KODRA-Scaffold installiert). Ein einzelnes zod-Schema ist
die alleinige Quelle der Wahrheit für Typen, Client-Validierung und Server-Validierung.
Regeln
- Schema zuerst. Definiere ein zod-Schema; leite den TypeScript-Typ mit
z.inferab. Lege gemeinsame Schemas insrc/lib/schemas/<name>.tsab, damit Server-Routen die gleiche Datei importieren können. - Validiere auch auf dem Server. Client-Validierung ist UX; die Server-Route, Edge-Funktion oder Datenbank-Constraint ist die Sicherheitsgrenze. Parse den Body erneut mit demselben Schema und gib Feld-Fehler zurück.
- Jedes Feld hat ein sichtbares Label, eine hilfreiche Beschreibung, wenn das Format nicht offensichtlich ist, und eine Inline-Fehlermeldung unter dem Feld.
- Validierungszeitpunkt:
mode: "onTouched"(validiere nach dem ersten Blur, dann bei jeder Änderung). Schreie Benutzer nicht an, während sie zum ersten Mal tippen. - Der Submit-Button bleibt aktiviert bis die Einreichung beginnt, zeigt dann einen ausstehenden Zustand an und ist deaktiviert, um Doppeleinreichungen zu verhindern.
- Bei Erfolg zeige, was passiert ist (Toast oder Inline-Nachricht) und setze zurück oder navigiere weiter. Bei Fehler behalte die Eingabe des Benutzers bei, zeige den Fehler in der Nähe des Formulars und bewege den Fokus auf das erste ungültige Feld (react-hook-form macht das standardmäßig).
- Verwende die richtige Eingabe:
type="email",inputMode="numeric",autoComplete-Token (name,email,tel,street-address,postal-code,one-time-code,new-password), damit Browser und Telefone helfen.
Kanonisches Formular
// src/lib/schemas/contact.ts
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().trim().min(2, "Gib deinen vollständigen Namen ein"),
email: z.string().trim().email("Gib eine gültige E-Mail ein, z.B. [email protected]"),
phone: z
.string()
.trim()
.regex(
/^0\d{8,9}$/,
"Gib eine thailändische Telefonnummer ein, die mit 0 beginnt (9-10 Ziffern)",
)
.optional()
.or(z.literal("")),
topic: z.enum(["booking", "billing", "other"], {
required_error: "Wähle ein Thema",
}),
message: z
.string()
.trim()
.min(10, "Erzähle uns etwas mehr (mindestens 10 Zeichen)")
.max(2000),
consent: z.boolean().refine((v) => v, "Bitte akzeptiere, um fortzufahren"),
});
export type ContactInput = z.infer<typeof contactSchema>;
// src/components/ContactForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { contactSchema, type ContactInput } from "@/lib/schemas/contact";
export function ContactForm() {
const form = useForm<ContactInput>({
resolver: zodResolver(contactSchema),
mode: "onTouched",
defaultValues: {
name: "",
email: "",
phone: "",
message: "",
consent: false,
},
});
async function onSubmit(values: ContactInput) {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (res.status === 422) {
const { fieldErrors } = (await res.json()) as {
fieldErrors: Partial<Record<keyof ContactInput, string>>;
};
for (const [field, message] of Object.entries(fieldErrors)) {
form.setError(field as keyof ContactInput, { type: "server", message });
}
return;
}
if (!res.ok) {
form.setError("root", {
message:
"Wir konnten deine Nachricht nicht senden. Überprüfe deine Verbindung und versuche es erneut.",
});
return;
}
toast.success("Nachricht gesendet. Wir antworten innerhalb eines Werktags.");
form.reset();
}
const { isSubmitting, errors } = form.formState;
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
noValidate
className="grid gap-5 sm:grid-cols-2"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Vollständiger Name</FormLabel>
<FormControl>
<Input autoComplete="name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-Mail</FormLabel>
<FormControl>
<Input type="email" autoComplete="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel>Thema</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Wähle ein Thema" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="booking">Buchung</SelectItem>
<SelectItem value="billing">Abrechnung</SelectItem>
<SelectItem value="other">Sonstiges</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefon (optional)</FormLabel>
<FormControl>
<Input
type="tel"
inputMode="tel"
autoComplete="tel"
{...field}
/>
</FormControl>
<FormDescription>Nur, wenn du einen Rückruf bevorzugst.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>Nachricht</FormLabel>
<FormControl>
<Textarea rows={5} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="consent"
render={({ field }) => (
<FormItem className="flex items-start gap-3 sm:col-span-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Ich stimme zu, zu dieser Anfrage kontaktiert zu werden
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
{errors.root && (
<p role="alert" className="text-sm text-destructive sm:col-span-2">
{errors.root.message}
</p>
)}
<div className="sm:col-span-2">
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting && (
<Loader2
className="mr-2 h-4 w-4 animate-spin"
aria-hidden="true"
/>
)}
{isSubmitting ? "Sendet..." : "Nachricht senden"}
</Button>
</div>
</form>
</Form>
);
}
Hinweise:
noValidatedeaktiviert native Browser-Bubbles, sodass nur zod-Meldungen angezeigt werden.- Übergib immer
defaultValues(leere Strings, nichtundefined), damit Eingaben ab dem ersten Rendern kontrolliert sind. - Verwende
value={field.value}bei Select/RadioGroup (nichtdefaultValue), damitform.reset()sie löscht. - Wenn das Formular über eine TanStack Query-Mutation abgeschickt wird, verwende
mutation.isPendinganstelle vonisSubmittingund rufemutateAsyncinonSubmitauf, damit react-hook-form darauf wartet.
Server-Validierung
Parse mit demselben Schema und gib 422 mit Feld-Fehlern zurück:
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ fieldErrors: parsed.error.flatten().fieldErrors },
{ status: 422 },
);
}
flatten().fieldErrors liefert string[] pro Feld; sende die erste Meldung
(fieldErrors.email?.[0]) oder passe die Client-Schleife an. Datenbank-Constraints (eindeutige E-Mail,
Fremdschlüssel) werden ebenfalls auf Feld-Fehler abgebildet: Fange die
Eindeutigkeitsverletzung ab und gib { fieldErrors: { email: "Diese E-Mail ist bereits registriert" } } zurück.
In Supabase-Apps mit reinem RLS-Zugriff validiere erneut in einer Datenbank-
Constraint oder einer Edge-Funktion; verlasse dich niemals auf den Client.
Stil der Fehlermeldungen
- Sage, was zu tun ist, nicht, was falsch gelaufen ist: „Gib ein Datum nach heute ein”, nicht „Ungültiges Datum”.
- Verwende die Begriffe des Benutzers und zeige das erwartete Format inline („TT/MM/JJJJ”).
- Eine Meldung pro Feld auf einmal; wiederhole das Label nicht.
- Schreibe Meldungen in der Sprache der App; zod-Meldungen sind einfache Strings, also übergib übersetzten Text (siehe das Thai-Lokalisierungs-Skill).
Checkliste der Zustände
- Leer: Labels, hilfreiche Platzhalter (Beispiele, keine Anweisungen).
- Tippen: keine Fehler bis zum ersten Blur.
- Ungültig: roter Rahmen + Meldung unter dem Feld (
FormMessage), Fokus springt beim Absenden zum ersten Fehler. - Einreichen: Button deaktiviert mit Spinner und Fortschrittslabel; Eingaben bleiben lesbar.
- Serverfehler: Meldung über dem Submit-Button mit
role="alert"; Eingabe erhalten. - Erfolg: Toast oder Inline-Bestätigung; Formular zurücksetzen oder Weiterleitung.
- Ungespeicherte Änderungen bei langen Formularen: Warnung vor dem Verlassen der Seite (
formState.isDirty).