Denne artikkelen er sponset av SurveyJS Det er en mental modell de fleste React-utviklere deler uten å diskutere det høyt. At skjemaer alltid skal være komponenter. Dette betyr en stabel som:
React Hook Form for lokal stat (minimal gjengivelse, ergonomisk feltregistrering, imperativ interaksjon). Zod for validering (inndatakorrekthet, grensevalidering, typesikker parsing). React Query for backend: innsending, gjenforsøk, caching, serversynkronisering og så videre.
Og for de aller fleste skjemaene - påloggingsskjermene dine, innstillingssidene dine, CRUD-modalene dine - fungerer dette veldig bra. Hver del gjør jobben sin, de komponerer rent, og du kan gå videre til de delene av applikasjonen som faktisk skiller produktet ditt. Men av og til begynner et skjema å samle opp ting som synlighetsregler som avhenger av tidligere svar, eller avledede verdier som går gjennom tre felt. Kanskje til og med hele sider som bør hoppes over eller vises basert på en løpende total. Du håndterer den første betingede med en useWatch og en inline-gren, noe som er greit. Så en annen. Da strekker du deg etter superRefine for å kode kryssfeltregler som Zod-skjemaet ditt ikke kan uttrykke på normal måte. Deretter begynner trinnnavigering å lekke forretningslogikk. På et tidspunkt ser du på hva du har bygget og innser at skjemaet egentlig ikke er brukergrensesnitt lenger. Det er mer en beslutningsprosess, og komponenttreet er akkurat der du tilfeldigvis lagret det. Det er her jeg tror den mentale modellen for skjemaer i React bryter sammen, og det er egentlig ingens feil. RHF + Zod-stakken er utmerket til det den er designet for. Problemet er at vi har en tendens til å fortsette å bruke det forbi det punktet hvor abstraksjonene samsvarer med problemet fordi alternativet krever en helt annen måte å tenke på former. Denne artikkelen handler om det alternativet. For å vise dette bygger vi nøyaktig samme flertrinnsskjema to ganger:
Med React Hook Form + Zod koblet til React Query for innsending, Med SurveyJS, som behandler et skjema som data – et enkelt JSON-skjema – i stedet for et komponenttre.
Samme krav, samme betingede logikk, samme API-kall på slutten. Deretter kartlegger vi nøyaktig hva som beveget seg og hva som ble igjen, og legger ut en praktisk måte å bestemme hvilken modell du skal bruke, og når. Skjemaet vi bygger:
Dette skjemaet bruker en 4-trinns flyt: Trinn 1: Detaljer
Fornavn (påkrevd), E-post (obligatorisk, gyldig format).
Trinn 2: Bestill
Enhetspris, Mengde, Skattesats, Avledet: Delsum, skatt, Totalt.
Trinn 3: Konto og tilbakemelding
Har du en konto? (Ja/Nei) Hvis Ja → brukernavn + passord, begge kreves. Hvis nei → e-post allerede samlet i trinn 1.
Tilfredshetsvurdering (1–5) Hvis ≥ 4 → spør "Hva likte du?" Hvis ≤ 2 → spør «Hva kan vi forbedre?»
Trinn 4: Gjennomgå
Vises bare hvis totalt >= 100 Endelig innlevering.
Dette er ikke ekstremt. Men det er nok til å avsløre arkitektoniske forskjeller. Del 1: Komponentdrevet (React Hook Form + Zod) Installasjon npm installer react-hook-form zod @hookform/resolvers @tanstack/react-query
Zod-skjema La oss starte med Zod-skjemaet, fordi det vanligvis er der formen på skjemaet blir etablert. For de to første trinnene – personlige detaljer og ordreinndata – er alt enkelt: påkrevde strenger, tall med minimumsverdier og en enum. Den interessante delen starter når du prøver å uttrykke de betingede reglene.
import { z } fra "zod";
eksport const formSchema = z.object({ fornavn: z.string().min(1, "Obligatorisk"), e-post: z.string().email("Ugyldig e-post"), pris: z.number().min(0), mengde: z.number().min(1), taxRate: z.number(), hasAccount: z.enum(No[".yes),op username(No[".yes),op username(No[".Yes). passord: z.string().optional(), tilfredshet: z.number().min(1).max(5), positivTilbakemelding: z.string().optional(), forbedringTilbakemelding: z.string().optional(),}).superRefine((data, ctx) => { if (data.hasAccount ===) "Yes" ctx.addIssue({ kode: "tilpasset", sti: ["brukernavn"], melding: "Obligatorisk" }); } if (!data.passord || data.passord.length < 6) { ctx.addIssue({ kode: "tilpasset", bane: ["passord"], melding: "Min 6 tegn"});
if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ kode: "custom", sti: ["positiveFeedback"], melding: "Vennligst del det du likte" }); }
if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ code: "custom", bane:["improvementFeedback"], melding: "Vennligst fortell oss hva vi skal forbedre" }); }});
eksporttype FormData = z.infer
Legg merke til at brukernavn og passord skrives inn som valgfritt() selv om de er betinget påkrevd fordi Zods typenivåskjema beskriver formen på objektet, ikke reglene som styrer når felt har betydning. Det betingede kravet må leve i superRefine, som kjører etter at formen er validert og har tilgang til hele objektet. At separasjon er ikke en feil; det er akkurat det verktøyet er designet for: superRefine er der kryssfeltlogikken går når den ikke kan uttrykkes i selve skjemastrukturen. Det som også er bemerkelsesverdig her er hva dette skjemaet ikke uttrykker. Den har ikke noe konsept for sider, ikke noe konsept for hvilke felt som er synlige på hvilket tidspunkt, og ikke noe konsept for navigasjon. Alt dette vil bo et annet sted. Skjemakomponent
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.Schema";
const STEPS = ["detaljer", "bestilling", "konto", "gjennomgang"];
type OrderPayload = FormData & { subtotal: number; skatt: nummer; totalt: antall };
eksportfunksjon RHFMultiStepForm() { const [step, setStep] = useState(0);
const mutation = brukMutasjon({ 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("Kunne ikke sende inn"); returner res.json(); }, });
const { register, control, handleSubmit, formState: { errors }, } = useForm
return (
);}Se Pen SurveyJS-03-RHF [forked] ved sixthextinction. Det er ganske mye som skjer her, og det er verdt å bremse ned for å legge merke til hvor ting endte opp.
De utledede verdiene – deltotal, skatt, total – beregnes i komponenten via useWatch og useMemo fordi de er avhengige av live-feltverdier og det ikke er noe annet naturlig sted for dem. Synlighetsreglene for brukernavn, passord, positive tilbakemeldinger og forbedringstilbakemeldinger lever i JSX som innebygde betingelser. Trinn-hoppingslogikken – gjennomgangssiden vises bare når totalt >= 100 – er innebygd i showSubmit-variabelen og gjengivelsesbetingelsen på trinn 3. Navigasjonen i seg selv er bare en useState-teller som vi øker manuelt. React Query håndterer gjenforsøk, bufring og ugyldiggjøring. Skjemaet kaller bare mutation.mutate med validerte data.
Ingenting av dette er feil i seg selv. Dette er fortsatt idiomatisk React, og komponenten er ganske effektiv takket være hvordan RHF isolerer re-rendering. Men hvis du skulle overlevere dette til noen som ikke hadde skrevet det og be dem forklare under hvilke forhold vurderingssiden vises, må de spore gjennom showSubmit, trinn 3-gjengivelsesbetingelsen og nav-knapplogikken – tre separate steder – for å rekonstruere en regel som kunne vært angitt på én linje. Skjemaet fungerer, ja, men oppførselen er egentlig ikke inspiserbar som et system. Det må utføres mentalt. Enda viktigere, å endre det krever ingeniørengasjement. Selv en liten justering, som å justere når gjennomgangstrinnet dukker opp, betyr å redigere komponenten, oppdatere validering, åpne en pull-forespørsel, vente på gjennomgang og distribuere på nytt. Del 2: Skjemadrevet (SurveyJS) La oss nå bygge den samme flyten ved hjelp av et skjema. Installasjon npm installer survey-core survey-react-ui @tanstack/react-query
survey-coreDen MIT-lisensierte plattformuavhengige kjøretidsmotoren som driver SurveyJSs formgjengivelse – delen vi bryr oss om her. Det tar et JSON-skjema, bygger en intern modell fra det, og håndterer alt som ellers ville levd i React-komponenten din: evaluere synlighetsuttrykk, beregne avledede verdier, administrere sidetilstand, spore validering og bestemme hva "fullstendig" betyr gitt hvilke sider som faktisk ble vist.
survey-react-ui Brukergrensesnittet / gjengivelseslaget som kobler den modellen til React. Det er i hovedsak en
Sammen gir de deg en fullt funksjonell, flersidig skjemakjøring uten å skrive en enkelt linje med kontrollflyt. Skjemaformatet i seg selv er, som sagt før, bare en JSON - ingen DSL eller noe proprietært. Du kan inline det, importere det fra en fil, hente det fra et API eller lagre det i en databasekolonne og hydrere det under kjøring. Samme form, som data Her er det samme skjemaet, denne gangen uttrykt som et JSON-objekt. Skjemaet definerer alt: struktur, validering, synlighetsregler, avledede beregninger, sidenavigering – og leverer det til en modell som evaluerer det under kjøring. Slik ser det ut i sin helhet:
export const surveySchema = { title: "Order Flow", showProgressBar: "top", pages: [ { name: "details", elements: [ { type: "text", name: "firstName", isRequired: true }, { type: "text", name: "email", inputType: "email", isRequired: true, "validerers": [{ type: " }, { name: "order", elementer: [ { type: "text", name: "price", inputType: "number", defaultValue: 0 }, { type: "text", name: "quantity", inputType: "number", defaultValue: 1 }, { type: "dropdown",name: "taxRate", defaultValue: 0.1, choices: [ { value: 0.05, text: "5%" }, { value: 0.1, text: "10%" }, { value: 0.15, text: "15%" } ] }, { type: "expression", name: "subtotal" }, type: "subtotal}" {, "expression", name: "tax", expression: "{subtotal} {taxRate}" }, { type: "expression", name: "total", expression: "{subtotal} + {tax}" } ] }, { name: "account", elements: [ { type: "radiogroup", name: "hasAccount", choices: ["]text "",:No name "us" visibleIf: "{hasAccount} = 'Ja'", erRequired: true }, { type: "text", name: "password", inputType: "password", visibleIf: "{hasAccount} = 'Yes'", er Påkrevd: true, validatorer: [{ type: "text", minLength: "6, characters": "Min:r type", {} type: " "satisfaction", rateMin: 1, rateMax: 5 }, { type: "comment", name: "positiveFeedback", visibleIf: "{satisfaction} >= 4" }, { type: "comment", name: "improvementFeedback", visibleIf: "{satisfaction} <= 2" }, {}tal >= 100", elementer: [] } ]};
Sammenlign dette med RHF-versjonen et øyeblikk.
SuperRefine-blokken som betinget krevde brukernavn og passord er borte. visibleIf: "{hasAccount} = 'Ja'" kombinert med isRequired: true håndterer begge bekymringene sammen, på selve feltet, der du forventer å finne dem. UseWatch + useMemo-kjeden som beregnet delsum, skatt og total er erstattet av tre uttrykksfelt som refererer til hverandre ved navn. Tilstanden for vurderingssiden, som i RHF-versjonen kun kunne rekonstrueres ved å spore gjennom showSubmit, trinn 3-gjengivelsesgrenen. Og til slutt, nav-knapplogikken er en enkelt visibleIf-egenskap på sideobjektet.
Den samme logikken er der. Det er bare at skjemaet gir det et sted å bo der det er synlig isolert, i stedet for spredt over komponenten. Vær også oppmerksom på at skjemaet bruker typen: 'uttrykk' for subtotal, tax og total. Uttrykket er skrivebeskyttet og brukes hovedsakelig til å vise beregnede verdier. SurveyJS støtter også type: 'html' for statisk innhold, men for beregnede verdier er uttrykk det riktige valget. Nå for React-siden. Gjengivelse og innlevering Veldig enkelt. Koble onComplete til API-en din på samme måte - via useMutation eller vanlig henting:
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"-;
eksportfunksjon SurveyForm() { const [modell] = useState(() => ny modell(surveySchema));
const mutation = brukMutasjon({ mutationFn: asynkron (data) => { const res = await fetch("/api/orders", { metode: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!res.ok) throw new Error("Kunne ikke sende inn"); returner res.json(); }, });
const mutationRef = brukRef(mutasjon); mutationRef.current = mutasjon; useEffect(() => { const handler = (sender) => mutationRef.current.mutate(sender.data); model.onComplete.add(handler); return () => model.onComplete.remove(handler); }, [modell]); // ref unngår omregistrering av behandler hver gjengivelse (mutasjonsobjektidentitet endres)
returnere (
<>
Se Pen SurveyJS-03-SurveyJS [forked] av sixthextinction.
onComplete utløses når brukeren kommer til slutten av den siste synlige siden. Så hvis totalen aldri krysser 100 og vurderingssiden hoppes over, utløses den fortsatt riktig fordi SurveyJS evaluerer synlighet før den bestemmer hva "siste side" betyr. Deretter inneholder sender.data alle svar sammen med de beregnede verdiene (subtotal, tax, total) som førsteklasses felt, slik at API-nyttelasten er identisk med det RHF-versjonen satte sammen manuelt i onSubmit. DemutationRef-mønsteret er det samme du vil strekke deg etter hvor som helst du trenger en stabil hendelsesbehandler over en verdi som endres på hver gjengivelse - ingenting SurveyJS-spesifikt ved det.
React-komponenten inneholder ikke lenger noen forretningslogikk i det hele tatt. Det er ingen useWatch, ingen betinget JSX, ingen skritteller, ingen useMemo-kjede, ingen superRefine. React gjør det den faktisk er god til: å gjengi en komponent og koble den til et API-kall. Hva flyttet ut av reaksjon?
Bekymring RHF-stakk SurveyJS Synlighet JSX grener synligHvis Avledede verdier useWatch / useMemo uttrykk Kryssfeltregler superRefine Skjemaforhold Navigasjon trinntilstand Side synligHvis Regel plassering Distribuert på tvers av filer Sentralisert i skjemaet
Det som forblir i React er layout, styling, innsendingsledninger og appintegrasjon, det vil si de tingene React faktisk er designet for. Alt annet flyttet inn i skjemaet, og fordi skjemaet bare er et JSON-objekt, kan det lagres i en database, versjonert uavhengig av applikasjonskoden din, eller redigeres gjennom internt verktøy uten å kreve en distribusjon. En produktsjef som trenger å endre terskelen som utløser gjennomgangssiden, kan gjøre det uten å berøre komponenten. Det er en meningsfull operasjonell forskjell for team der formadferd utvikler seg ofte og ikke alltid er drevet av ingeniører. Når skal man bruke hver tilnærming? Her er en god tommelfingerregel som fungerer for meg: forestill deg å slette skjemaet helt. Hva ville du tapt?
Hvis det er skjermer, vil du ha komponentdrevne skjemaer. Hvis det er forretningslogikk, som terskler, forgreningsregler og betingede krav som koder for reelle beslutninger, vil du ha en skjemamotor.
På samme måte, hvis endringene som kommer din vei hovedsakelig handler om etiketter, felt og layout, vil RHF tjene deg bra. Hvis de handler om forhold, utfall og regler som din oper eller juridiske team kanskje må justere på en tirsdag ettermiddag uten å sende inn en billett, er skjemamodellen med SurveyJS den mer ærlige passformen. Disse to tilnærmingene er egentlig ikke i konkurranse med hverandre. De tar for seg ulike klasser av problemer, og feilen som er verdt å unngå er å mismatche abstraksjonen med vekten av logikken – å behandle et regelsystem som en komponent fordi det er det kjente verktøyet, eller å strekke seg etter en policymotor fordi en form vokste til tre trinn og fikk et betinget felt. Formen vi bygde her sitter nær grensen bevisst, kompleks nok til å avsløre forskjellen, men ikke så ekstrem at sammenligningen føles rigget. De fleste virkelige skjemaer som har blitt uhåndterlige i kodebasen din, ligger sannsynligvis nær den samme grensen, og spørsmålet er vanligvis bare om noen har navngitt hva de faktisk er. Bruk React Hook Form + Zod når:
Skjemaer er CRUD-orienterte; Logikken er grunn og UI-drevet; Ingeniører eier all oppførsel; Backend forblir kilden til sannhet.
Bruk SurveyJS når:
Skjemaer koder for forretningsbeslutninger; Regler utvikler seg uavhengig av brukergrensesnittet; Logikken må være synlig, reviderbar eller versjonert; Ikke-ingeniører påvirker atferd; Det samme skjemaet må kjøre på tvers av flere grensesnitt.