Formulare sind das Herzstück fast jeder Web-Applikation. React Hook Form (RHF) hat sich als de-facto Standard für performante, typsichere Formulare in React durchgesetzt — und mit Claude Code lässt sich die Komplexität von Multi-Step-Wizards, dynamischen Feldlisten und Server Actions drastisch reduzieren.
In diesem Guide gehen wir über die Grundlagen hinaus: Du lernst die Patterns, die professionelle Teams 2026 verwenden — von useFieldArray über Controller-basierte Custom-Komponenten bis hin zur Integration mit React 19 Server Actions.
Themen in diesem Artikel
- Grundlagen & Performance-Philosophie von React Hook Form
- Typsichere Validierung mit Zod und
zodResolver - Dynamische Felder mit
useFieldArrayinkl. Drag & Drop - Controller-Pattern für Custom Components (Radix UI, DatePicker)
- Multi-Step Formulare mit State-Management & Step-Validation
- React 19 Server Actions mit
useActionState
1. React Hook Form Grundlagen
RHF unterscheidet sich fundamental von kontrollierten Formularen: Es arbeitet mit uncontrolled inputs, was Re-Renders minimiert und die Performance massiv verbessert. Claude Code generiert RHF-Setups sofort korrekt.
Der wichtigste Unterschied zu useState-basierten Formularen: React Hook Form speichert den Formular-State in einem internen ref-Objekt, nicht im React-State. Das bedeutet: kein Re-Render bei jedem Tastendruck.
Core API: useForm, register, handleSubmit
import { useForm } from 'react-hook-form' import type { SubmitHandler } from 'react-hook-form' // 1. Interface für strenge Typisierung interface UserProfileForm { name: string email: string age: number bio?: string newsletter: boolean } export function UserProfileForm() { // 2. useForm mit Generic-Typ const { register, handleSubmit, formState: { errors, isSubmitting, isDirty, isValid }, watch, reset, setValue, getValues } = useForm<UserProfileForm>({ defaultValues: { name: '', email: '', age: 18, newsletter: false }, mode: 'onBlur' // Validierung beim Verlassen des Feldes }) // 3. Submit-Handler mit korrektem Typ const onSubmit: SubmitHandler<UserProfileForm> = async (data) => { // data ist vollständig typisiert! await saveProfile(data) } // 4. Watch für reaktive Werte (sparingly einsetzen!) const newsletterValue = watch('newsletter') return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('name', { required: 'Name ist erforderlich', minLength: { value: 2, message: 'Mindestens 2 Zeichen' } })} placeholder="Vollständiger Name" /> {errors.name && <span className="error">{errors.name.message}</span>} <input type="email" {...register('email', { required: 'E-Mail erforderlich', pattern: { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message: 'Ungültige E-Mail-Adresse' } })} /> {errors.email && <span className="error">{errors.email.message}</span>} <button type="submit" disabled={isSubmitting || !isDirty || !isValid}> {isSubmitting ? 'Speichern...' : 'Profil speichern'} </button> </form> ) }
formState im Detail
| Property | Typ | Bedeutung |
|---|---|---|
errors |
FieldErrors | Validierungsfehler aller Felder |
isSubmitting |
boolean | True während handleSubmit läuft |
isDirty |
boolean | True wenn Werte von defaultValues abweichen |
isValid |
boolean | True wenn alle Validierungen bestanden |
touchedFields |
Record | Felder die der User berührt hat |
dirtyFields |
Record | Geänderte Felder (granular) |
Sage Claude: "Erstelle ein React Hook Form Setup mit TypeScript, Zod-Validierung und formState für [dein Use Case]." Claude generiert sofort das vollständige typisierte Setup inklusive Fehlerhandling.
2. Zod-Integration & Typsichere Validierung
Zod + RHF = perfekte Kombination: Schema als Single Source of Truth für Validierung und TypeScript-Types. Kein doppelter Code mehr.
Mit @hookform/resolvers und Zod definierst du dein Schema einmal — und erhältst automatisch Validierung und TypeScript-Typen. Claude Code versteht dieses Pattern perfekt und kann komplexe Schemas mit nested Objects, Arrays und Conditional Validation generieren.
import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' // Schema als einzige Wahrheitsquelle const addressSchema = z.object({ street: z.string().min(3, 'Straße erforderlich'), city: z.string().min(2, 'Stadt erforderlich'), zip: z.string().regex(/^\d{5}$/, 'PLZ muss 5 Ziffern haben'), country: z.enum(['DE', 'AT', 'CH']) }) const checkoutSchema = z.object({ // Nested Objects billing: addressSchema, shipping: addressSchema.optional(), // Conditional Validation sameAddress: z.boolean(), email: z.string().email('Ungültige E-Mail'), phone: z.string().optional(), // Enum mit Custom Message paymentMethod: z.enum(['card', 'paypal', 'sepa'], { errorMap: () => ({ message: 'Zahlungsmethode wählen' }) }), // Number mit Transform amount: z.number() .min(10, 'Mindestbetrag: 10€') .max(10000, 'Maximalbetrag: 10.000€') }).refine( // Cross-field Validation: wenn sameAddress=false, shipping Pflicht (data) => data.sameAddress || data.shipping !== undefined, { message: 'Lieferadresse erforderlich wenn abweichend', path: ['shipping'] } ) // Type aus Schema ableiten — KEIN separates Interface! type CheckoutForm = z.infer<typeof checkoutSchema> function CheckoutForm() { const { register, handleSubmit, formState: { errors }, watch } = useForm<CheckoutForm>({ resolver: zodResolver(checkoutSchema), defaultValues: { sameAddress: true, paymentMethod: 'card' } }) const sameAddress = watch('sameAddress') // errors.billing.street ist vollständig typisiert! return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('billing.street')} /> {errors.billing?.street && ( <p>{errors.billing.street.message}</p> )} {!sameAddress && ( </* Lieferadresse Felder */> /* ... */ </> )} </form> ) }
Was Zod besser macht als manuelle Validierung
- Type Inference:
z.infer<typeof schema>— kein doppeltes Interface - Cross-Field Validation:
.refine()für Abhängigkeiten zwischen Feldern - Transforms: Input-Strings automatisch zu Numbers/Dates konvertieren
- Discriminated Unions: Verschiedene Schemas je nach Typ-Auswahl
- Async Validation:
.refine(async () => ...)für Server-Checks
3. useFieldArray — Dynamische Felder
useFieldArray ist das leistungsstärkste Feature von RHF für dynamische Listen. Claude Code generiert komplexe Drag & Drop-fähige Feldlisten auf Zuruf.
Ob Zutaten-Listen, Team-Mitglieder, URL-Arrays oder Preisregeln — useFieldArray macht dynamische Formularlisten zu einem Kinderspiel. Der Trick: Jedes Element bekommt eine stabile id von RHF, was Drag & Drop ermöglicht.
import { useForm, useFieldArray } from 'react-hook-form' import { DndContext, closestCenter } from '@dnd-kit/core' import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' interface Recipe { title: string ingredients: Array<{ name: string amount: string unit: string }> } // Einzelne sortierbare Zutat function SortableIngredient({ id, index, register, remove, errors }) { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }) const style = { transform: CSS.Transform.toString(transform), transition } return ( <div ref={setNodeRef} style={style} className="ingredient-row"> {/* Drag Handle */} <button type="button" {...attributes} {...listeners} className="drag-handle"> ⠿ </button> <input {...register(`ingredients.${index}.name`, { required: 'Name erforderlich' })} placeholder="Zutat" /> <input {...register(`ingredients.${index}.amount`)} placeholder="Menge" type="number" style={{ width: '80px' }} /> <select {...register(`ingredients.${index}.unit`)}> <option value="g">g</option> <option value="kg">kg</option> <option value="ml">ml</option> <option value="EL">EL</option> <option value="TL">TL</option> </select> <button type="button" onClick={() => remove(index)}>✕</button> </div> ) } export function RecipeForm() { const { register, handleSubmit, control, formState: { errors } } = useForm<Recipe>({ defaultValues: { ingredients: [{ name: '', amount: '', unit: 'g' }] } }) const { fields, append, remove, move } = useFieldArray({ control, // control aus useForm! name: 'ingredients' }) // Drag & Drop Handler function handleDragEnd(event) { const { active, over } = event if (active.id !== over?.id) { const oldIndex = fields.findIndex(f => f.id === active.id) const newIndex = fields.findIndex(f => f.id === over.id) move(oldIndex, newIndex) } } return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('title', { required: true })} placeholder="Rezeptname" /> <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <SortableContext items={fields} strategy={verticalListSortingStrategy}> {fields.map((field, index) => ( <SortableIngredient key={field.id} {/* IMMER field.id, nicht index! */} id={field.id} index={index} register={register} remove={remove} errors={errors} /> ))} </SortableContext> </DndContext> <button type="button" onClick={() => append({ name: '', amount: '', unit: 'g' })} > + Zutat hinzufügen </button> <button type="submit">Rezept speichern</button> </form> ) }
Niemals key={index} bei useFieldArray verwenden! Immer key={field.id} — RHF vergibt stabile IDs. Mit Index als Key verlieren Inputs beim Reorder ihren Fokus und die Werte werden vertauscht.
useFieldArray API-Übersicht
Alle useFieldArray Methoden
append(value)— Element ans Ende anfügenprepend(value)— Element an den Anfang stelleninsert(index, value)— An beliebiger Position einfügenremove(index)— Element entfernen (Array oder einzeln)move(from, to)— Reihenfolge ändern (Drag & Drop)swap(indexA, indexB)— Zwei Elemente tauschenupdate(index, value)— Element direkt aktualisierenreplace(newArray)— Gesamte Liste ersetzen
4. Controller für Custom Components
Wenn register nicht funktioniert (Custom Components ohne native Input-Referenz), ist Controller die Lösung. Perfekt für Radix UI, DatePicker und eigene Select-Komponenten.
Manche Komponenten — besonders aus Design-System-Libraries wie Radix UI, Headless UI oder externe DatePicker — exponieren keine native ref. Hier kommt Controller ins Spiel: Es rendert die Komponente und übergibt value und onChange.
import { useForm, Controller } from 'react-hook-form' import * as Select from '@radix-ui/react-select' import { DatePicker } from './DatePicker' interface EventForm { title: string category: 'workshop' | 'webinar' | 'conference' startDate: Date endDate: Date priority: 1 | 2 | 3 } export function EventForm() { const { register, control, handleSubmit, formState: { errors } } = useForm<EventForm>() return ( <form onSubmit={handleSubmit(onSubmit)}> {/* Native Input: register reicht */} <input {...register('title', { required: true })} /> {/* Radix UI Select: braucht Controller */} <Controller name="category" control={control} rules={{ required: 'Kategorie wählen' }} render={({ field: { onChange, value, ref }, fieldState: { error } }) => ( <> <Select.Root value={value} onValueChange={onChange}> <Select.Trigger ref={ref} aria-invalid={!!error}> <Select.Value placeholder="Typ wählen" /> </Select.Trigger> <Select.Content> <Select.Item value="workshop">Workshop</Select.Item> <Select.Item value="webinar">Webinar</Select.Item> <Select.Item value="conference">Konferenz</Select.Item> </Select.Content> </Select.Root> {error && <span>{error.message}</span>} </> )} /> {/* DatePicker mit Date-Objekt */} <Controller name="startDate" control={control} rules={{ required: 'Startdatum wählen' }} render={({ field: { onChange, value }, fieldState: { error } }) => ( <> <DatePicker selected={value} onChange={onChange} locale="de" dateFormat="dd.MM.yyyy" placeholderText="Startdatum" className={error ? 'error' : ''} /> {error && <span>{error.message}</span>} </> )} /> <button type="submit">Event erstellen</button> </form> ) }
Faustregel: Wenn die Komponente ein native HTML-Element ist (input, select, textarea) → register. Wenn es eine Custom-Komponente ist die eigene value/onChange Props hat → Controller. Performance-technisch ist register immer vorzuziehen.
5. Multi-Step Formulare — Wizard-Pattern
Multi-Step-Formulare mit RHF: Ein einzelnes useForm für alle Schritte, Step-Validierung mit trigger(), und persistenter State zwischen den Schritten.
import { useState } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' // Gesamtes Schema — ein Schema für alle Steps const registrationSchema = z.object({ // Step 1: Persönlich firstName: z.string().min(2), lastName: z.string().min(2), email: z.string().email(), birthDate: z.string(), // Step 2: Adresse street: z.string().min(3), city: z.string().min(2), zip: z.string().regex(/^\d{5}$/), // Step 3: Account username: z.string().min(3).regex(/^[a-z0-9_]+$/), password: z.string().min(8), passwordConfirm: z.string() }).refine(d => d.password === d.passwordConfirm, { message: 'Passwörter stimmen nicht überein', path: ['passwordConfirm'] }) type Registration = z.infer<typeof registrationSchema> // Welche Felder gehören zu welchem Step? const STEP_FIELDS: Record<number, (keyof Registration)[]> = { 0: ['firstName', 'lastName', 'email', 'birthDate'], 1: ['street', 'city', 'zip'], 2: ['username', 'password', 'passwordConfirm'] } export function RegistrationWizard() { const [currentStep, setCurrentStep] = useState(0) const totalSteps = 3 const { register, handleSubmit, trigger, // Wichtig: manuell validieren! formState: { errors, isValid } } = useForm<Registration>({ resolver: zodResolver(registrationSchema), mode: 'onChange' }) // Nur aktuellen Step validieren, dann weiter const handleNext = async () => { const fieldsToValidate = STEP_FIELDS[currentStep] const isStepValid = await trigger(fieldsToValidate) if (isStepValid) { setCurrentStep(prev => Math.min(prev + 1, totalSteps - 1)) } } const handleBack = () => { setCurrentStep(prev => Math.max(prev - 1, 0)) } const onFinalSubmit = handleSubmit(async (data) => { await registerUser(data) }) return ( <div> {/* Progress Bar */} <div role="progressbar" aria-valuenow={currentStep + 1} aria-valuemax={totalSteps}> <div style={{ width: `${((currentStep + 1) / totalSteps) * 100}%` }} /> </div> <form onSubmit={onFinalSubmit}> {/* Step 0: Persönliche Daten */} {currentStep === 0 && ( <fieldset> <legend>Persönliche Daten</legend> <input {...register('firstName')} placeholder="Vorname" /> {errors.firstName && <p>{errors.firstName.message}</p>} <input {...register('lastName')} placeholder="Nachname" /> <input {...register('email')} type="email" placeholder="E-Mail" /> </fieldset> )} {/* Step 1: Adresse */} {currentStep === 1 && ( <fieldset> <legend>Adresse</legend> <input {...register('street')} placeholder="Straße & Hausnummer" /> <input {...register('zip')} placeholder="PLZ" /> <input {...register('city')} placeholder="Stadt" /> </fieldset> )} {/* Step 2: Account */} {currentStep === 2 && ( <fieldset> <legend>Account erstellen</legend> <input {...register('username')} placeholder="Benutzername" /> <input {...register('password')} type="password" /> <input {...register('passwordConfirm')} type="password" /> {errors.passwordConfirm && <p>{errors.passwordConfirm.message}</p>} </fieldset> )} {/* Navigation */} <div> {currentStep > 0 && ( <button type="button" onClick={handleBack}>Zurück</button> )} {currentStep < totalSteps - 1 ? ( <button type="button" onClick={handleNext}>Weiter</button> ) : ( <button type="submit">Registrieren</button> )} </div> </form> </div> ) }
Ein useForm für alle Steps
Der entscheidende Vorteil dieses Ansatzes: Alle Felder bleiben im selben RHF-Kontext. Das bedeutet:
- Werte bleiben erhalten wenn der User zurückgeht
- Zod kann Cross-Step-Validierungen durchführen (z.B. Passwort-Bestätigung)
- Der finale Submit-Handler bekommt alle Felder auf einmal
- Kein manuelles State-Management zwischen Steps nötig
6. Server Actions + React 19
React 19 bringt useActionState und Server Actions — kombiniert mit RHF entstehen hybride Formulare die Client- und Server-Validierung vereinen.
Mit React 19 und Next.js 15 (App Router) können Formular-Actions direkt auf dem Server ausgeführt werden. Claude Code versteht die Integration von RHF mit useActionState und kann serverseitige Validierung mit Zod generieren.
// app/actions/contact.ts (Server-seitig) 'use server' import { z } from 'zod' const contactSchema = z.object({ name: z.string().min(2), email: z.string().email(), message: z.string().min(10).max(1000) }) export type ActionState = { status: 'idle' | 'success' | 'error' errors?: Record<string, string[]> message?: string } export async function submitContact( prevState: ActionState, formData: FormData ): Promise<ActionState> { // Server-seitige Validierung (IMMER — nie nur Client vertrauen!) const result = contactSchema.safeParse({ name: formData.get('name'), email: formData.get('email'), message: formData.get('message') }) if (!result.success) { return { status: 'error', errors: result.error.flatten().fieldErrors } } try { // Business Logic await sendContactEmail(result.data) return { status: 'success', message: 'Nachricht gesendet!' } } catch (error) { return { status: 'error', message: 'Fehler beim Senden' } } } // --- // app/components/ContactForm.tsx (Client-seitig) 'use client' import { useActionState, useEffect } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { submitContact } from '../actions/contact' export function ContactForm() { // useActionState: React 19 API (ersetzt useFormState) const [state, formAction, isPending] = useActionState( submitContact, { status: 'idle' } ) const { register, handleSubmit, setError, // Server-Fehler in RHF übernehmen reset, formState: { errors } } = useForm({ resolver: zodResolver(contactSchema) }) // Server-Fehler in RHF-Fehlerzustand übernehmen useEffect(() => { if (state.status === 'error' && state.errors) { Object.entries(state.errors).forEach(([field, messages]) => { setError(field as any, { type: 'server', message: messages[0] }) }) } if (state.status === 'success') { reset() // Formular leeren nach Erfolg } }, [state, setError, reset]) // Client-Validierung zuerst, dann Server Action const onSubmit = handleSubmit(async (data) => { const formData = new FormData() Object.entries(data).forEach(([k, v]) => formData.append(k, String(v))) formAction(formData) }) return ( <form onSubmit={onSubmit}> <input {...register('name')} /> {errors.name && <span>{errors.name.message}</span>} <input {...register('email')} type="email" /> <textarea {...register('message')} rows={5} /> {state.status === 'success' && ( <p role="status">✓ {state.message}</p> )} <button type="submit" disabled={isPending}> {isPending ? 'Senden...' : 'Nachricht senden'} </button> </form> ) }
Server Actions richtig nutzen
- Doppelte Validierung: Client-Validierung via RHF+Zod für UX, Server-Validierung als Sicherheitsnetz
- useActionState statt useState: React 19 API für automatisches pending/state Management
- setError für Server-Fehler: Server-Validierungsfehler direkt in RHF-Fehlerzustand schreiben
- FormData + RHF hybrid: handleSubmit für Client-Validierung, dann FormData für Server Action
- Progressive Enhancement: Formular funktioniert auch ohne JavaScript (native form action)
Performance-Optimierung mit Claude Code
Der größte Performance-Gewinn kommt aus dem Verständnis, wann Re-Renders passieren. Claude Code kann RHF-Setups analysieren und Optimierungsmöglichkeiten identifizieren.
| Pattern | Re-Renders | Empfehlung |
|---|---|---|
watch() ohne Selector |
Bei jedem Keystroke | Nur für spezifische Felder nutzen |
watch('field') |
Nur bei diesem Feld | Bevorzugen |
useWatch() |
Subscribed only | Beste Option für Sub-Komponenten |
getValues() |
Kein Re-Render | Für Event-Handler (onClick etc.) |
Controller |
Bei eigenem Feld | Nur wenn register nicht geht |
import { useWatch } from 'react-hook-form' // Sub-Komponente reagiert nur auf relevante Felder function PricePreview({ control }) { // useWatch subscribed ONLY to these two fields const [quantity, unitPrice] = useWatch({ control, name: ['quantity', 'unitPrice'] }) const total = (quantity || 0) * (unitPrice || 0) return ( <div className="price-preview"> Gesamt: <strong>{total.toFixed(2)}€</strong> </div> ) } // Vermeiden: watch() im Parent der alles neu rendert function FormParent() { const { register, control } = useForm() // Parent rendert NICHT neu wenn quantity/unitPrice sich ändert return ( <form> <input {...register('quantity', { valueAsNumber: true })} type="number" /> <input {...register('unitPrice', { valueAsNumber: true })} type="number" /> {/* Nur PricePreview rendert neu */} <PricePreview control={control} /> </form> ) }
Claude Code für RHF-Formulare einsetzen
Claude Code versteht das gesamte RHF-Ecosystem und kann auf Zuruf vollständige, typsichere Formular-Setups generieren. Die effektivsten Prompts sind konkret und nennen die gewünschten Features:
So arbeitest du mit Claude Code für Formulare
- "Erstelle ein Multi-Step-Registrierungsformular mit RHF + Zod, 4 Schritte: Persönliches, Adresse, Account, Bestätigung. TypeScript, zodResolver, trigger() für Step-Validierung."
- "Füge useFieldArray zu diesem Rezept-Formular hinzu mit Drag & Drop über dnd-kit. Jede Zutat hat Name, Menge, Einheit."
- "Refaktoriere dieses Formular: Ersetze alle watch()-Aufrufe im Parent durch useWatch() in Sub-Komponenten für bessere Performance."
- "Baue eine Server Action für dieses Kontaktformular mit React 19 useActionState, server-seitiger Zod-Validierung und setError() für Fehlersynchronisation."
Claude Code kann bestehende Formulare analysieren und gezielt optimieren: Performance-Bottlenecks durch zu viele watch()-Subscriptions, fehlende Zod-Validierung, nicht typisierte handleSubmit-Handler — alles identifizierbar und behebbar in einem Prompt.
Zusammenfassung: RHF 2026 Cheatsheet
Die wichtigsten Regeln auf einen Blick
- register für native Inputs, Controller für Custom Components
- Zod + zodResolver = Schema als Single Source of Truth
- key={field.id} bei useFieldArray — niemals key={index}
- trigger(fields) für Step-Validierung in Multi-Step-Formularen
- useWatch() in Sub-Komponenten statt watch() im Parent
- getValues() in Event-Handlern wenn kein Re-Render nötig
- useActionState + Server Actions für React 19 Hybrid-Formulare
- mode: 'onBlur' für bessere UX bei Validierung
Formulare schneller entwickeln mit Claude Code
Generiere vollständige, typsichere React Hook Form Setups in Sekunden. useFieldArray, Controller, Zod-Schemas, Multi-Step-Wizards — Claude Code kennt alle Patterns.
Kostenlos testen — Jetzt starten