Dieser Artikel wird von SurveyJS gesponsert Es gibt ein mentales Modell, das die meisten React-Entwickler teilen, ohne es jemals laut zu diskutieren. Dass Formen immer Komponenten sein sollen. Dies bedeutet einen Stapel wie:

Reagieren Sie auf das Hook-Formular für den lokalen Status (minimale erneute Renderings, ergonomische Feldregistrierung, zwingende Interaktion). Zod zur Validierung (Eingabekorrektheit, Grenzvalidierung, typsicheres Parsen). Reagieren Sie auf die Abfrage für das Backend: Übermittlung, Wiederholungsversuche, Caching, Serversynchronisierung usw.

Und für die überwiegende Mehrheit der Formulare – Ihre Anmeldebildschirme, Ihre Einstellungsseiten, Ihre CRUD-Modalitäten – funktioniert das wirklich gut. Jedes Teil erfüllt seine Aufgabe, es setzt sich sauber zusammen und Sie können mit den Teilen Ihrer Anwendung fortfahren, die Ihr Produkt tatsächlich auszeichnen. Aber hin und wieder fängt ein Formular an, Dinge wie Sichtbarkeitsregeln anzusammeln, die von früheren Antworten abhängen, oder abgeleitete Werte, die durch drei Felder kaskadieren. Vielleicht sogar ganze Seiten, die übersprungen oder basierend auf einer laufenden Summe angezeigt werden sollten. Sie behandeln die erste Bedingung mit einer useWatch und einem Inline-Zweig, was in Ordnung ist. Dann noch einer. Dann greifen Sie zu superRefine, um feldübergreifende Regeln zu kodieren, die Ihr Zod-Schema nicht auf normale Weise ausdrücken kann. Dann beginnt die Schrittnavigation, Geschäftslogik preiszugeben. Irgendwann schauen Sie sich an, was Sie erstellt haben, und stellen fest, dass das Formular nicht mehr wirklich eine Benutzeroberfläche ist. Es handelt sich eher um einen Entscheidungsprozess, und der Komponentenbaum ist genau der Ort, an dem Sie ihn gespeichert haben. Hier bricht meiner Meinung nach das mentale Modell für Formen in React zusammen, und daran ist wirklich niemand schuld. Der RHF + Zod-Stack erfüllt hervorragend das, wofür er entwickelt wurde. Das Problem besteht darin, dass wir dazu neigen, es über den Punkt hinaus weiter zu verwenden, an dem seine Abstraktionen mit dem Problem übereinstimmen, weil die Alternative eine völlig andere Denkweise über Formen erfordert. In diesem Artikel geht es um diese Alternative. Um dies zu zeigen, erstellen wir zweimal genau dasselbe mehrstufige Formular:

Wenn React Hook Form + Zod zur Übermittlung mit React Query verbunden ist, Mit SurveyJS, das ein Formular als Daten – ein einfaches JSON-Schema – und nicht als Komponentenbaum behandelt.

Gleiche Anforderungen, gleiche bedingte Logik, gleicher API-Aufruf am Ende. Anschließend erfassen wir genau, was umgezogen ist und was geblieben ist, und erläutern eine praktische Methode für die Entscheidung, welches Modell Sie wann verwenden sollten. Das Formular, das wir erstellen:

Dieses Formular verwendet einen 4-Schritte-Ablauf: Schritt 1: Details

Vorname (erforderlich), E-Mail (erforderlich, gültiges Format).

Schritt 2: Bestellen

Stückpreis, Menge, Steuersatz, Abgeleitet: Zwischensumme, Steuer, Insgesamt.

Schritt 3: Konto und Feedback

Haben Sie ein Konto? (Ja/Nein) Wenn Ja → Benutzername + Passwort, beides erforderlich. Wenn Nein → E-Mail bereits in Schritt 1 erfasst.

Zufriedenheitsbewertung (1–5) Wenn ≥ 4 → fragen Sie „Was hat Ihnen gefallen?“ Wenn ≤ 2 → fragen Sie: „Was können wir verbessern?“

Schritt 4: Überprüfen

Erscheint nur, wenn die Summe >= 100 ist Endgültige Einreichung.

Das ist nicht extrem. Aber es reicht aus, um architektonische Unterschiede aufzudecken. Teil 1: Komponentengesteuert (React Hook Form + Zod) Installation npm install React-Hook-Form zod @hookform/resolvers @tanstack/react-query

Zod-Schema Beginnen wir mit dem Zod-Schema, denn dort wird normalerweise die Form des Formulars festgelegt. Für die ersten beiden Schritte – persönliche Daten und Bestelleingaben – ist alles unkompliziert: erforderliche Zeichenfolgen, Zahlen mit Mindestangaben und eine Aufzählung. Der interessante Teil beginnt, wenn Sie versuchen, die bedingten Regeln auszudrücken.

import { z } from „zod“;

export const formSchema = z.object({ firstName: z.string().min(1, "Required"), email: z.string().email("Invalid email"), price: z.number().min(0), amount: z.number().min(1), taxRate: z.number(), hasAccount: z.enum(["Yes", "No"]), username: z.string().optional(), Passwort: z.string().optional(), Zufriedenheit: z.number().min(1).max(5), positivesFeedback: z.string().optional(), VerbesserungFeedback: z.string().optional(),}).superRefine((data, ctx) => { if (data.hasAccount === "Yes") { if (!data.username) { ctx.addIssue({ code: "custom", path: ["username"], message: "Erforderlich" }); } if (!data.password || data.password.length < 6) { ctx.addIssue({ code: "custom", path: ["password"], message: "Min. 6 Zeichen" } }

if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ code: "custom", path: ["positiveFeedback"], message: "Bitte teilen Sie mit, was Ihnen gefallen hat" }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ code: "custom", path:["improvementFeedback"], Nachricht: "Bitte teilen Sie uns mit, was wir verbessern sollen" }); }});

export type FormData = z.infer;

Beachten Sie, dass Benutzername und Passwort als optional() eingegeben werden, obwohl sie bedingt erforderlich sind, da Zods Schema auf Typebene die Form des Objekts beschreibt und nicht die Regeln, die regeln, wann Felder wichtig sind. Die bedingte Anforderung muss in superRefine enthalten sein, das nach der Validierung der Form ausgeführt wird und Zugriff auf das gesamte Objekt hat. Diese Trennung ist kein Fehler; Genau dafür ist das Tool konzipiert: SuperRefine ist der Ort, an dem feldübergreifende Logik eingesetzt wird, wenn sie nicht in der Schemastruktur selbst ausgedrückt werden kann. Bemerkenswert ist hier auch, was dieses Schema nicht ausdrückt. Es gibt kein Konzept für Seiten, kein Konzept dafür, welche Felder an welcher Stelle sichtbar sind und kein Konzept für die Navigation. All das wird woanders leben. Formularkomponente

import { useForm, useWatch } from „react-hook-form“;import { zodResolver } from „@hookform/resolvers/zod“;import { useMutation } from „@tanstack/react-query“;import { useState, useMemo } from „react“;import { formSchema, type FormData } from „./schema“;

const STEPS = ["details", "order", "account", "review"];

type OrderPayload = FormData & { subtotal: number; Steuer: Nummer; total: Zahl };

Exportfunktion RHFMultiStepForm() { const [step, setStep] = useState(0);

const mutation = useMutation({ mutationFn: async (Nutzlast: OrderPayload) => { const res = waiting fetch("/api/orders", { Methode: „POST“, Header: { "Content-Type": "application/json" }, Körper: JSON.stringify(Nutzlast), }); if (!res.ok) throw new Error("Fehler beim Senden"); return res.json(); }, });

const { register, control, handleSubmit, formState: { Fehler }, } = useForm({ Resolver: zodResolver(formSchema), defaultValues: { Preis: 0, Menge: 1, Steuersatz: 0,1, Zufriedenheit: 3, HasAccount: "Nein", }, }); const price = useWatch({ control, name: "price" }); const amount = useWatch({ control, name: "quantity" }); const taxRate = useWatch({ control, name: "taxRate" }); const hasAccount = useWatch({ control, name: "hasAccount" }); const Satisfaction = useWatch({ control, name: "satisfaction" }); const subtotal = useMemo(() => (Preis ?? 0) * (Menge ?? 1), [Preis, Menge]); const tax = useMemo(() => subtotal * (taxRate ?? 0), [subtotal, taxRate]); const total = useMemo(() => Zwischensumme + Steuer, [Zwischensumme, Steuer]); const onSubmit = (data: FormData) => mutation.mutate({ ...data, subtotal, tax, total }); const showSubmit = (Schritt === 2 && Gesamt < 100) || (Schritt === 3 && Gesamt >= 100)

return (

{step === 0 && ( <> )}

{step === 1 && ( <>

Zwischensumme: {subtotal}
Steuer: {tax}
Gesamt: {total}
)}

{step === 2 && ( <>

{hasAccount === "Ja" && ( <> )}

{Zufriedenheit >= 4 && (