React & Formulare 5. Mai 2026 · 11 min Lesezeit

React Hook Form mit Claude Code:
Fortgeschrittene Formulare 2026

useFieldArray, Controller, watch, Multi-Step Formulare, Server Actions und Performance-Optimierung — so entwickelst du professionelle React-Formulare mit KI-Unterstützung.

← Zurück zum Blog

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.

Was du lernst

Themen in diesem Artikel

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

TypeScript — Basis-Setup mit RHF + TypeScript
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)
Claude Code Prompt-Tipp

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.

TypeScript — Zod Schema + zodResolver + Type Inference
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>
  )
}
Zod-Power

Was Zod besser macht als manuelle Validierung

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.

useFieldArray

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.

TypeScript — useFieldArray mit Drag & Drop (dnd-kit)
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>
  )
}
Kritischer Fehler: Index als Key

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

API

Alle useFieldArray Methoden

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.

TypeScript — Controller mit Radix UI Select + Custom DatePicker
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>
  )
}
register vs Controller — wann was?

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.

Multi-Step
Schritt 1 Persönliches
Schritt 2 Adresse
Schritt 3 Konto
Schritt 4 Bestätigung
TypeScript — Multi-Step Wizard mit Step-Validation
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>
  )
}
Schlüssel-Prinzip

Ein useForm für alle Steps

Der entscheidende Vorteil dieses Ansatzes: Alle Felder bleiben im selben RHF-Kontext. Das bedeutet:

6. Server Actions + React 19

React 19 bringt useActionState und Server Actions — kombiniert mit RHF entstehen hybride Formulare die Client- und Server-Validierung vereinen.

React 19

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.

TypeScript — Server Action + useActionState + RHF
// 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>
  )
}
Best Practices React 19 + RHF

Server Actions richtig nutzen

Performance-Optimierung mit Claude Code

Performance

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
TypeScript — useWatch für performance-kritische Sub-Komponenten
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:

Effektive Prompts

So arbeitest du mit Claude Code für Formulare

Claude Code Workflow

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

Quick Reference

Die wichtigsten Regeln auf einen Blick

React Hook Form TypeScript Zod useFieldArray Controller Multi-Step Form Server Actions React 19 Claude Code Performance

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
🤖
agentic-movers Redaktion
Praxisnahe Guides zu KI-gestützter Webentwicklung mit Claude Code