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({ resolver: zodResolver(formSchema), defaultValues: { price: 0, quantity: 1, taxRate: 0.1, satisfaction: 3, hasAccount: "No", }, }); const price = useWatch({ kontroll, navn: "pris" }); const quantity = useWatch({ control, name: "quantity" }); const taxRate = useWatch({ kontroll, navn: "taxRate" }); const hasAccount = useWatch({ kontroll, navn: "hasAccount" }); const satisfaction = useWatch({ kontroll, navn: "tilfredshet" }); const subtotal = useMemo(() => (pris ?? 0) * (antall ?? 1), [pris, mengde]); 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 = (trinn === 2 && totalt < 100) || (trinn === 3 && totalt >= 100)

return (

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

{trinn === 1 && ( <> true

Subtotal: {subtotal}
Skatt: {tax}
Total: {total}
)}

{trinn === 2 && ( <>

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

{tilfredshet >= 4 && (