Denne artikel er sponsoreret af SurveyJS Der er en mental model, som de fleste React-udviklere deler uden nogensinde at diskutere det højt. At formularer altid skal være komponenter. Dette betyder en stak som:

React Hook Form for lokal stat (minimal gengivelse, ergonomisk feltregistrering, imperativ interaktion). Zod til validering (input korrekthed, grænsevalidering, typesikker parsing). React Query til backend: indsendelse, genforsøg, cachelagring, serversynkronisering og så videre.

Og for langt de fleste formularer - dine login-skærme, dine indstillingssider, dine CRUD-modaler - fungerer dette rigtig godt. Hvert stykke gør sit arbejde, de komponerer rent, og du kan gå videre til de dele af din applikation, der faktisk adskiller dit produkt. Men en gang imellem begynder en formular at akkumulere ting som synlighedsregler, der afhænger af tidligere svar, eller afledte værdier, der kaskade gennem tre felter. Måske endda hele sider, der bør springes over eller vises baseret på en løbende total. Du håndterer den første betingede med et useWatch og en inline-gren, hvilket er fint. Så en anden. Så rækker du efter superRefine for at indkode krydsfeltsregler, som dit Zod-skema ikke kan udtrykke på normal vis. Derefter begynder trinnavigation at lække forretningslogik. På et tidspunkt ser du på, hvad du har bygget, og indser, at formen ikke rigtig er brugergrænseflade længere. Det er mere en beslutningsproces, og komponenttræet er lige der, hvor du tilfældigvis opbevarede det. Det er her, jeg tror, ​​at den mentale model for formularer i React bryder sammen, og det er virkelig ingens skyld. RHF + Zod-stakken er fremragende til det, den er designet til. Problemet er, at vi har en tendens til at blive ved med at bruge det forbi det punkt, hvor dets abstraktioner matcher problemet, fordi alternativet kræver en helt anden måde at tænke former på. Denne artikel handler om dette alternativ. For at vise dette bygger vi nøjagtig den samme flertrinsformular to gange:

Med React Hook Form + Zod kablet til React Query til indsendelse, Med SurveyJS, som behandler en formular som data - et simpelt JSON-skema - snarere end et komponenttræ.

Samme krav, samme betingede logik, samme API-kald til sidst. Så kortlægger vi præcis, hvad der flyttede sig, og hvad der blev, og lægger en praktisk måde at beslutte, hvilken model du skal bruge, og hvornår. Formen vi bygger:

Denne formular vil bruge et 4-trins flow: Trin 1: Detaljer

Fornavn (påkrævet), E-mail (påkrævet, gyldigt format).

Trin 2: Bestil

Enhedspris, Mængde, Skattesats, Afledt: Subtotal, skat, I alt.

Trin 3: Konto og feedback

Har du en konto? (Ja/Nej) Hvis Ja → brugernavn + adgangskode, begge påkrævet. Hvis nej → e-mail allerede indsamlet i trin 1.

Tilfredshedsvurdering (1-5) Hvis ≥ 4 → spørg "Hvad kunne du lide?" Hvis ≤ 2 → spørg "Hvad kan vi forbedre?"

Trin 4: Gennemgå

Vises kun hvis total >= 100 Endelig aflevering.

Dette er ikke ekstremt. Men det er nok til at afsløre arkitektoniske forskelle. Del 1: Komponentdrevet (React Hook Form + Zod) Installation npm installer react-hook-form zod @hookform/resolvers @tanstack/react-query

Zod Skema Lad os starte med Zod-skemaet, for det er normalt her, formen bliver etableret. For de første to trin - personlige detaljer og ordreinput - er alt ligetil: påkrævede strenge, tal med minimumsværdier og en enum. Den interessante del starter, når du forsøger at udtrykke de betingede regler.

import { z } fra "zod";

eksport const formSchema = z.object({ fornavn: z.string().min(1, "Påkrævet"), e-mail: z.string().email("Ugyldig e-mail"), pris: z.number().min(0), antal: z.number().min(1), taxRate: z.number(), hasAccount: z.enum(No[".string),op username(No[".Yes),op username(No[".Yes). adgangskode: z.string().optional(), tilfredshed: z.number().min(1).max(5), positivFeedback: z.string().optional(), forbedringFeedback: z.string().optional(),}).superRefine((data, ctx) => { if (data.hasAccount ===) "Yes" ctx.addIssue({ kode: "brugerdefineret", sti: ["brugernavn"], meddelelse: "Påkrævet" } } if (!data.password || data.addIssue < 6) { ctx.addIssue({ kode: "custom", sti: ["adgangskode"], besked: "Min 6 tegn"});

if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ kode: "custom", sti: ["positiveFeedback"], besked: "Del venligst, hvad du kunne lide" }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ kode: "custom", sti:["improvementFeedback"], besked: "Fortæl os venligst, hvad vi skal forbedre" }); }});

eksporttype FormData = z.infer;

Bemærk, at brugernavn og adgangskode indtastes som valgfri(), selvom de er betinget påkrævet, fordi Zods type-niveau-skema beskriver formen på objektet, ikke reglerne, der styrer, hvornår felter betyder noget. Det betingede krav skal leve inde i superRefine, som kører efter formen er valideret og har adgang til hele objektet. Den adskillelse er ikke en fejl; det er lige hvad værktøjet er designet til: superRefine er der, hvor tværfeltslogik går, når det ikke kan udtrykkes i selve skemastrukturen. Hvad der også er bemærkelsesværdigt her, er hvad dette skema ikke udtrykker. Den har intet begreb om sider, intet begreb om, hvilke felter der er synlige på hvilket tidspunkt, og intet begreb om navigation. Alt det vil leve et andet sted. Formularkomponent

import { useForm, useWatch } fra "react-hook-form";import { zodResolver } fra "@hookform/resolvers/zod";import { useMutation } fra "@tanstack/react-query";import { useState, useMemo } fra "react";import { formSchema} fra "Form.DataSchema};

const STEPS = ["detaljer", "ordre", "konto", "gennemgang"];

type OrderPayload = FormData & { subtotal: number; skat: nummer; total: antal };

eksportfunktion RHFMultiStepForm() { const [trin, setStep] = useState(0);

const mutation = brugMutation({ mutationFn: async (nyttelast: OrderPayload) => { const res = await fetch("/api/orders", { metode: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(nyttelast), }); if (!res.ok) throw new Error("Kunnede ikke indsende"); returner res.json(); }, });

const { register, control, handleSubmit, formState: { errors }, } = useForm({ resolver: zodResolver(formSchema), defaultValues: { price: 0, quantity: 1, taxRate: 0.1, satisfaction: 3, hasAccount: "No", }, }); const price = useWatch({ kontrol, navn: "pris" }); const quantity = useWatch({ kontrol, navn: "mængde" }); const taxRate = useWatch({ kontrol, navn: "taxRate" }); const hasAccount = useWatch({ kontrol, navn: "hasAccount" }); const satisfaction = useWatch({ kontrol, navn: "tilfredshed" }); const subtotal = useMemo(() => (pris ?? 0) * (mængde ?? 1), [pris, mængde]); const tax = useMemo(() => subtotal * (taxRate ?? 0), [subtotal, taxRate]); const total = useMemo(() => subtotal + tax, [subtotal, tax]); const onSubmit = (data: FormData) => mutation.mutate({ ...data, subtotal, tax, total }); const showSubmit = (trin === 2 && i alt < 100) || (trin === 3 && i alt >= 100)

returner (

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

{trin === 1 && ( <>

Subtotal: {subtotal}
Moms: {tax}
I alt: {total}
)}

{trin === 2 && ( <>

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

{tilfredshed >= 4 && (