CommunityCodierung & Entwicklunggithub.com

ko-system/forms-validation

Barrierefreie Formulare mit react-hook-form, zod und shadcn/ui Form, inklusive Inline-Fehlermeldungen, ausstehenden und deaktivierten Zuständen, Erfolgsrückmeldung und serverseitiger Validierung mit gemeinsamen Schemata.

Was ist forms-validation?

Diese Fähigkeit definiert einen Best-Practice-Workflow für die Formularerstellung in React-Anwendungen, die auf dem KODRA-Scaffold aufbauen. Sie kombiniert `react-hook-form` für Formularzustand und -validierung, `zod` für Schemadefinition und Typsicherheit, und shadcn/ui-Komponenten für eine barrierefreie und konsistente Benutzeroberfläche. Der Kernansatz ist „Schema zuerst”: Ein einzelnes `zod`-Schema dient als alleinige Quelle der Wahrheit für TypeScript-Typen, clientseitige Validierung und serverseitige Validierung. Die Fähigkeit bietet klare Regeln für Beschriftungen, Fehlermeldungen, Validierungszeitpunkt (Modus `onTouched`) und Zustände während der Einreichung (deaktiviert mit Spinner). Sie enthält ein vollständiges kanonisches Beispiel (`ContactForm`), das Inline-Fehler für jedes Feld, Bestätigungsmeldungen per `sonner`, und die Zuordnung von serverseitigen Feld-fehlern zu `form.setError` demonstriert. Ein separates Server-Validierungsbeispiel zeigt, wie das gleiche Schema auf einem API-Endpunkt geparst und bei Fehlern ein 422-Status mit `flatten().fieldErrors` zurückgegeben wird. Die Anleitung schließt mit einer Checkliste für alle Formularzustände und Richtlinien für Fehlermeldungen im Benutzerstil.

Funktioniert mit~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/ko-system/kodra-skills/tree/HEAD/skills/forms-validation

Installed? Explore more Codierung & Entwicklung skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

In Ihrer bevorzugten KI fragen

Öffnet einen neuen Chat, in dem dieser Agent-Skill bereits geladen ist.

Dokumentation

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

  1. Schema zuerst. Definiere ein zod-Schema; leite den TypeScript-Typ mit z.infer ab. Lege gemeinsame Schemas in src/lib/schemas/<name>.ts ab, damit Server-Routen die gleiche Datei importieren können.
  2. 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.
  3. Jedes Feld hat ein sichtbares Label, eine hilfreiche Beschreibung, wenn das Format nicht offensichtlich ist, und eine Inline-Fehlermeldung unter dem Feld.
  4. Validierungszeitpunkt: mode: "onTouched" (validiere nach dem ersten Blur, dann bei jeder Änderung). Schreie Benutzer nicht an, während sie zum ersten Mal tippen.
  5. Der Submit-Button bleibt aktiviert bis die Einreichung beginnt, zeigt dann einen ausstehenden Zustand an und ist deaktiviert, um Doppeleinreichungen zu verhindern.
  6. 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).
  7. 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:

  • noValidate deaktiviert native Browser-Bubbles, sodass nur zod-Meldungen angezeigt werden.
  • Übergib immer defaultValues (leere Strings, nicht undefined), damit Eingaben ab dem ersten Rendern kontrolliert sind.
  • Verwende value={field.value} bei Select/RadioGroup (nicht defaultValue), damit form.reset() sie löscht.
  • Wenn das Formular über eine TanStack Query-Mutation abgeschickt wird, verwende mutation.isPending anstelle von isSubmitting und rufe mutateAsync in onSubmit auf, 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).

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

Verwandte Skills