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
returner (
);}Se Pen SurveyJS-03-RHF [forked] ved sixthextinction. Der sker en del her, og det er værd at sætte farten ned for at lægge mærke til, hvor tingene er endt.
De afledte værdier - subtotal, tax, total - beregnes i komponenten via useWatch og useMemo, fordi de afhænger af live-feltværdier, og der ikke er noget andet naturligt sted for dem. Synlighedsreglerne for brugernavn, adgangskode, positiv feedback og forbedringFeedback lever i JSX som inline-betingelser. Logikken for trinoverspringning - gennemgangssiden vises kun, når total >= 100 - er indlejret i showSubmit-variablen og gengivelsesbetingelsen på trin 3. Navigation i sig selv er kun en useState-tæller, som vi manuelt øger. React Query håndterer genforsøg, cachelagring og ugyldiggørelse. Formen kalder bare mutation.mutate med validerede data.
Intet af dette er i sig selv forkert. Dette er stadig idiomatisk React, og komponenten er ret effektiv takket være, hvordan RHF isolerer re-renders. Men hvis du skulle aflevere dette til en, der ikke havde skrevet det, og bede dem om at forklare, under hvilke forhold anmeldelsessiden vises, ville de skulle spore gennem showSubmit, trin 3-gengivelsesbetingelsen og nav-knappens logik - tre separate steder - for at rekonstruere en regel, der kunne have været angivet på én linje. Formen virker, ja, men adfærden er ikke rigtig inspektionsbar som system. Det skal udføres mentalt. Endnu vigtigere, at ændre det kræver ingeniørinvolvering. Selv en lille tweak, som at justere, når gennemgangstrinnet dukker op, betyder at redigere komponenten, opdatere validering, åbne en pull-anmodning, vente på gennemgang og implementere igen. Del 2: Skemadrevet (SurveyJS) Lad os nu bygge det samme flow ved hjælp af et skema. Installation npm installer survey-core survey-react-ui @tanstack/react-query
survey-coreDen MIT-licenserede platform-uafhængige runtime-motor, der driver SurveyJS's formgengivelse - den del, vi holder af her. Det tager et JSON-skema, bygger en intern model ud fra det og håndterer alt, hvad der ellers ville bo i din React-komponent: evaluering af synlighedsudtryk, beregning af afledte værdier, styring af sidetilstand, sporingsvalidering og beslutning om, hvad "fuldstændig" betyder, givet hvilke sider der rent faktisk blev vist.
survey-react-ui Brugergrænsefladen/gengivelseslaget, der forbinder denne model med React. Det er i bund og grund en
Sammen giver de dig en fuldt funktionel, flersidet formruntime uden at skrive en enkelt linje med kontrolflow. Selve skemaformatet er, som sagt før, kun en JSON - ingen DSL eller noget proprietært. Du kan inline det, importere det fra en fil, hente det fra en API eller gemme det i en databasekolonne og hydrere det under kørsel. Den samme form, som data Her er den samme form, denne gang udtrykt som et JSON-objekt. Skemaet definerer alt: struktur, validering, synlighedsregler, afledte beregninger, sidenavigation - og giver det til en model, der evaluerer det under kørsel. Sådan ser det ud i sin helhed:
export const surveySchema = { title: "Order Flow", showProgressBar: "top", sider: [ { navn: "detaljer", elementer: [ { type: "text", navn: "firstName", isRequired: true }, { type: "text", name: "email", inputType: "email", isRequired: true, "validerer": [{ type: true, "validerer": [{ type:"] }, { name: "order", elements: [ { type: "text", name: "price", inputType: "number", defaultValue: 0 }, { type: "text", name: "quantity", inputType: "number", defaultValue: 1 }, { type: "dropdown",navn: "taxRate", defaultValue: 0,1, valg: [ { value: 0,05, text: "5%" }, { value: 0,1, text: "10%" }, { value: 0,15, text: "15%" } ] }, { type: "expression", name: "subtotal" }, type: "subtotal}" {, "udtryk", navn: "skat", udtryk: "{subtotal} {taxRate}" }, { type: "expression", navn: "total", udtryk: "{subtotal} + {tax}" } ] }, { navn: "konto", elementer: [ { type: "radiogroup", navn: "hasAccount", valg: [""] "Ja",: "Nej" "navn", "us" visibleIf: "{hasAccount} = 'Ja'", er Påkrævet: sand }, { type: "text", navn: "adgangskode", inputType: "adgangskode", visibleIf: "{hasAccount} = 'Ja'", er Påkrævet: sand, validatorer: [{ type: "tekst", minLængde: "6, tegn": "Min:r type", tekst: "Min:r" "satisfaction", rateMin: 1, rateMax: 5 }, { type: "comment", name: "positiveFeedback", visibleIf: "{satisfaction} >= 4" }, { type: "comment", name: "improvementFeedback", visibleIf: "{tilfredshed} <= 2" }, {}tal til at se: ", {} >= 100", elementer: [] } ]};
Sammenlign dette med RHF-versionen et øjeblik.
SuperRefine-blokken, der betinget krævede brugernavn og adgangskode, er væk. visibleIf: "{hasAccount} = 'Ja'" kombineret med isRequired: true håndterer begge problemer sammen, på selve feltet, hvor du ville forvente at finde dem. UseWatch + useMemo-kæden, der beregnede subtotal, skat og total, erstattes af tre udtryksfelter, der refererer til hinanden ved navn. Betingelsen for anmeldelsessiden, som i RHF-versionen kun kunne rekonstrueres ved at spore gennem showSubmit, trin 3-gengivelsesgrenen. Og endelig er nav-knaplogikken en enkelt visibleIf-egenskab på sideobjektet.
Den samme logik er der. Det er bare, at skemaet giver det et sted at bo, hvor det er synligt isoleret, snarere end spredt ud over komponenten. Bemærk også, at skemaet bruger typen: 'udtryk' for subtotal, tax og total. Udtryk er skrivebeskyttet og bruges hovedsageligt til at vise beregnede værdier. SurveyJS understøtter også typen: 'html' for statisk indhold, men for beregnede værdier er udtryk det rigtige valg. Nu til React-siden. Gengivelse og indsendelse Meget simpelt. Forbind påComplete til din API på samme måde - via useMutation eller almindelig hentning:
import { useState, useEffect, useRef } fra "react";import { useMutation } fra "@tanstack/react-query";import { Model } fra "survey-core";import { Survey } fra "survey-react-ui";import "survey-core/survey"-;
eksportfunktion SurveyForm() { const [model] = useState(() => ny Model(surveySchema));
const mutation = brugMutation({ mutationFn: async (data) => { const res = await fetch("/api/orders", { metode: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!res.ok) throw new Error("Kunnede ikke indsende"); returner res.json(); }, });
const mutationRef = brugRef(mutation); mutationRef.current = mutation; useEffect(() => { const handler = (afsender) => mutationRef.current.mutate(sender.data); model.onComplete.add(handler); return () => model.onComplete.remove(handler); }, [model]); // ref undgår at omregistrere behandler hver gengivelse (mutationsobjektidentitet ændres)
returnere (
<>
Se Pen SurveyJS-03-SurveyJS [forked] ved sixthextinction.
onComplete udløses, når brugeren når slutningen af den sidst synlige side. Så hvis totalen aldrig krydser 100, og anmeldelsessiden springes over, udløses den stadig korrekt, fordi SurveyJS evaluerer synlighed, før den beslutter, hvad "sidste side" betyder. Derefter indeholder sender.data alle svar sammen med de beregnede værdier (subtotal, tax, total) som førsteklasses felter, så API-nyttelasten er identisk med hvad RHF-versionen monterede manuelt i onSubmit. DemutationRef-mønsteret er det samme, du vil nå til overalt, hvor du har brug for en stabil hændelseshandler over en værdi, der ændres ved hver gengivelse - intet SurveyJS-specifikt ved det.
React-komponenten indeholder ikke længere nogen forretningslogik overhovedet. Der er ingen useWatch, ingen betinget JSX, ingen skridttæller, ingen useMemo-kæde, ingen superRefine. React gør, hvad den faktisk er god til: at gengive en komponent og forbinde den til et API-kald. Hvad flyttede ud af reaktion?
Bekymring RHF stak SurveyJS Synlighed JSX filialer synligHvis Afledte værdier useWatch / useMemo udtryk Cross-field regler superfine Skemaforhold Navigation trintilstand Side synligHvis Regl placering Fordelt på tværs af filer Centraliseret i skemaet
Det, der bliver i React, er layout, styling, indsendelsesledninger og app-integration, hvilket vil sige de ting, som React faktisk er designet til. Alt andet er flyttet ind i skemaet, og fordi skemaet kun er et JSON-objekt, kan det lagres i en database, versioneres uafhængigt af din applikationskode eller redigeres gennem internt værktøj uden at kræve en implementering. En produktchef, der skal ændre tærskelværdien, der udløser anmeldelsessiden, kan gøre det uden at røre ved komponenten. Det er en meningsfuld operationel forskel for teams, hvor formadfærd udvikler sig ofte og ikke altid er drevet af ingeniører. Hvornår skal man bruge hver tilgang? Her er en god tommelfingerregel, der virker for mig: forestil dig at slette formularen helt. Hvad ville du miste?
Hvis det er skærme, vil du have komponentdrevne formularer. Hvis det er forretningslogik, såsom tærskler, forgreningsregler og betingede krav, der koder for rigtige beslutninger, vil du have en skemamotor.
På samme måde, hvis ændringerne, der kommer din vej, hovedsageligt handler om etiketter, felter og layout, vil RHF tjene dig fint. Hvis de handler om forhold, resultater og regler, som din ops eller juridiske team muligvis skal justere på en tirsdag eftermiddag uden at indgive en billet, er skemamodellen med SurveyJS den mere ærlige pasform. Disse to tilgange er ikke rigtig i konkurrence med hinanden. De adresserer forskellige klasser af problemer, og den fejl, der er værd at undgå, er at mismatche abstraktionen med vægten af logikken - at behandle et regelsystem som en komponent, fordi det er det velkendte værktøj, eller at række ud efter en politikmotor, fordi en form voksede til tre trin og fik et betinget felt. Formen, vi byggede her, ligger bevidst nær grænsen, kompleks nok til at afsløre forskellen, men ikke så ekstrem, at sammenligningen føles rigget. De fleste rigtige former, der er blevet uhåndterlige i din kodebase, sidder sandsynligvis tæt på den samme grænse, og spørgsmålet er normalt bare, om nogen har navngivet, hvad de faktisk er. Brug React Hook Form + Zod når:
Formularer er CRUD-orienterede; Logikken er overfladisk og UI-drevet; Ingeniører ejer al adfærd; Backend forbliver kilden til sandheden.
Brug SurveyJS når:
Formularer koder forretningsbeslutninger; Regler udvikler sig uafhængigt af brugergrænsefladen; Logik skal være synlig, auditerbar eller versioneret; Ikke-ingeniører påvirker adfærd; Den samme formular skal køre på tværs af flere frontends.