Dit artikel wordt gesponsord door SurveyJS Er is een mentaal model dat de meeste React-ontwikkelaars delen zonder het ooit hardop te bespreken. Dat formulieren altijd componenten moeten zijn. Dit betekent een stapel als:

React Hook-formulier voor lokale staat (minimale herweergave, ergonomische veldregistratie, dwingende interactie). Zod voor validatie (invoercorrectheid, grensvalidatie, typeveilige parsing). Reageren Query voor backend: indiening, nieuwe pogingen, caching, serversynchronisatie, enzovoort.

En voor de overgrote meerderheid van de formulieren – uw inlogschermen, uw instellingenpagina’s, uw CRUD-modaliteiten – werkt dit heel goed. Elk onderdeel doet zijn werk, ze zijn netjes samengesteld en u kunt doorgaan met de delen van uw toepassing die uw product daadwerkelijk onderscheiden. Maar af en toe begint een formulier zaken te verzamelen, zoals zichtbaarheidsregels die afhankelijk zijn van eerdere antwoorden, of afgeleide waarden die door drie velden lopen. Misschien zelfs hele pagina's die moeten worden overgeslagen of weergegeven op basis van een lopend totaal. Je handelt de eerste voorwaardelijke af met een useWatch en een inline branch, wat prima is. Dan nog een. Vervolgens grijpt u naar superRefine om veldoverschrijdende regels te coderen die uw Zod-schema niet op de normale manier kan uitdrukken. Vervolgens begint stapnavigatie bedrijfslogica te lekken. Op een gegeven moment kijk je naar wat je hebt gebouwd en besef je dat het formulier niet echt meer een gebruikersinterface is. Het is meer een besluitvormingsproces, en de componentenboom is precies waar je hem toevallig hebt opgeslagen. Dit is waar ik denk dat het mentale model voor formulieren in React kapot gaat, en dat is echt niemands schuld. De RHF + Zod-stack is uitstekend waarvoor hij is ontworpen. Het probleem is dat we de neiging hebben om het te blijven gebruiken voorbij het punt waarop de abstracties overeenkomen met het probleem, omdat het alternatief een geheel andere manier van denken over vormen vereist. Dit artikel gaat over dat alternatief. Om dit te laten zien, bouwen we tweemaal exact hetzelfde meerstapsformulier:

Met React Hook Form + Zod aangesloten op React Query voor indiening, Met SurveyJS, dat een formulier als gegevens behandelt (een eenvoudig JSON-schema) in plaats van als een componentenboom.

Dezelfde vereisten, dezelfde voorwaardelijke logica, dezelfde API-aanroep aan het einde. Vervolgens brengen we precies in kaart wat er is verplaatst en wat is gebleven, en leggen we een praktische manier uit om te beslissen welk model u moet gebruiken, en wanneer. Het formulier dat we bouwen:

Dit formulier gebruikt een stroom van 4 stappen: Stap 1: Details

Voornaam (verplicht), E-mail (vereist, geldig formaat).

Stap 2: Bestellen

Eenheidsprijs, Hoeveelheid, Belastingtarief, Afgeleid: Subtotaal, Belasting, Totaal.

Stap 3: Account & Feedback

Heeft u een account? (Ja/Nee) Indien Ja → gebruikersnaam + wachtwoord, beide vereist. Indien Nee → e-mail al verzameld in stap 1.

Tevredenheidsscore (1–5) Als ≥ 4 → vraag “Wat vond je leuk?” Als ≤ 2 → vraag “Wat kunnen we verbeteren?”

Stap 4: Beoordeling

Verschijnt alleen als totaal >= 100 Definitieve indiening.

Dit is niet extreem. Maar het is genoeg om architecturale verschillen bloot te leggen. Deel 1: Component-aangedreven (React Hook Form + Zod) Installatie npm installeer react-hook-form zod @hookform/resolvers @tanstack/react-query

Zod-schema Laten we beginnen met het Zod-schema, omdat daar meestal de vorm van de vorm wordt vastgesteld. Voor de eerste twee stappen – persoonlijke gegevens en orderinvoer – is alles eenvoudig: vereiste reeksen, getallen met minima en een opsomming. Het interessante deel begint wanneer je de voorwaardelijke regels probeert uit te drukken.

importeer { z } uit "zod";

export const formSchema = z.object({ firstName: z.string().min(1, "Vereist"), email: z.string().email("Ongeldige e-mail"), prijs: z.number().min(0), hoeveelheid: z.number().min(1), taxRate: z.number(), hasAccount: z.enum(["Ja", "Nee"]), gebruikersnaam: z.string().optioneel(), wachtwoord: z.string().optioneel(), tevredenheid: z.number().min(1).max(5), positiveFeedback: z.string().optioneel(), verbeteringFeedback: z.string().optioneel(),}).superRefine((data, ctx) => { if (data.hasAccount === "Ja") { if (!data.username) { ctx.addIssue({ code: "custom", pad: ["gebruikersnaam"], bericht: "Vereist" }); } if (!data.password || data.password.length < 6) { ctx.addIssue({ code: "aangepast", pad: ["wachtwoord"], bericht: "Min. 6 tekens" } });

if (data.satisfaction >= 4 && !data.positiveFeedback) {ctx.addIssue({ code: "custom", pad: ["positiveFeedback"], bericht: "Deel alstublieft wat u leuk vond" }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ code: "aangepast", pad:["improvementFeedback"], bericht: "Vertel ons alstublieft wat we kunnen verbeteren" }); }});

exporttype FormData = z.infer;

Merk op dat gebruikersnaam en wachtwoord als optioneel() worden getypt, ook al zijn ze voorwaardelijk vereist, omdat het typeniveauschema van Zod de vorm van het object beschrijft, en niet de regels die bepalen wanneer velden er toe doen. De voorwaardelijke vereiste moet in superRefine leven, die wordt uitgevoerd nadat de vorm is gevalideerd en toegang heeft tot het volledige object. Die scheiding is geen fout; het is precies waarvoor de tool is ontworpen: superRefine is waar veldoverschrijdende logica naartoe gaat als deze niet in de schemastructuur zelf kan worden uitgedrukt. Wat hier ook opvalt, is wat dit schema niet uitdrukt. Het heeft geen concept van pagina's, geen concept van welke velden op welk punt zichtbaar zijn, en geen concept van navigatie. Dat zal allemaal ergens anders leven. Vormcomponent

importeer { useForm, useWatch } uit "react-hook-form"; importeer { zodResolver } uit "@hookform/resolvers/zod"; importeer { useMutation } uit "@tanstack/react-query"; importeer { useState, useMemo } uit "react"; importeer { formSchema, type FormData } uit "./schema";

const STEPS = ["details", "bestelling", "account", "beoordeling"];

type OrderPayload = FormData & {subtotaal: nummer; belasting: aantal; totaal: aantal };

exportfunctie RHFMultiStepForm() {const [stap, setStep] = useState(0);

const mutatie = useMutation({ mutatieFn: async (payload: OrderPayload) => { const res = wachten op ophalen("/api/orders", { methode: "POST", headers: { "Contenttype": "applicatie/json" }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error("Kan niet indienen"); retourneer res.json(); }, });

const { register, control, handleSubmit, formState: { fouten }, } = useForm({ solver: zodResolver(formSchema), defaultValues: { prijs: 0, hoeveelheid: 1, taxRate: 0,1, tevredenheid: 3, hasAccount: "Nee", }, }); const prijs = useWatch({ controle, naam: "prijs" }); const aantal = useWatch({ controle, naam: "hoeveelheid" }); const taxRate = useWatch({ controle, naam: "taxRate" }); const hasAccount = useWatch({ controle, naam: "hasAccount" }); const tevredenheid = useWatch({ controle, naam: "tevredenheid" }); const subtotaal = useMemo(() => (prijs € 0) * (hoeveelheid € 1), [prijs, hoeveelheid]); const tax = useMemo(() => subtotaal * (taxRate ?? 0), [subtotaal, taxRate]); const totaal = useMemo(() => subtotaal + belasting, [subtotaal, belasting]); const onSubmit = (gegevens: FormData) => Mutation.mutate({ ...data, subtotaal, belasting, totaal }); const showSubmit = (stap === 2 && totaal < 100) || (stap === 3 && totaal >= 100)

return (

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

{step === 1 && ( <>

Subtotaal: {subtotaal
Belasting: {tax}
Totaal: {totaal
)}

{stap === 2 && ( <>

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

{tevredenheid >= 4 && (