Acest articol este sponsorizat de SurveyJS Există un model mental pe care majoritatea dezvoltatorilor React îl împărtășesc fără să-l discute vreodată cu voce tare. Aceste forme ar trebui să fie întotdeauna componente. Aceasta înseamnă o stivă ca:

Formular React Hook pentru statul local (redari minime, înregistrare ergonomică a câmpului, interacțiune imperativă). Zod pentru validare (corectitudinea intrării, validarea limitelor, parsare sigură de tip). React Query pentru backend: trimitere, reîncercări, stocare în cache, sincronizare server și așa mai departe.

Și pentru marea majoritate a formularelor - ecranele dvs. de conectare, paginile de setări, modalele CRUD - acest lucru funcționează foarte bine. Fiecare piesă își face treaba, compun curat și puteți trece la părțile aplicației dvs. care vă diferențiază de fapt produsul. Dar din când în când, un formular începe să acumuleze lucruri cum ar fi reguli de vizibilitate care depind de răspunsurile anterioare sau valori derivate care trec în cascadă prin trei câmpuri. Poate chiar pagini întregi care ar trebui sărite sau afișate pe baza unui total cumulat. Primul condiționat îl gestionați cu un useWatch și o ramură inline, ceea ce este bine. Apoi altul. Apoi apelați la superRefine pentru a codifica regulile încrucișate pe care schema dvs. Zod nu le poate exprima în mod normal. Apoi, navigarea pasilor începe să scurgă logica de afaceri. La un moment dat, te uiți la ceea ce ai construit și realizezi că formularul nu mai este cu adevărat UI. Este mai mult un proces de decizie, iar arborele componente este exact locul în care s-a întâmplat să îl stocați. Aici cred că modelul mental al formelor din React se defectează și nu este vina nimănui. Stiva RHF + Zod este excelentă pentru ceea ce a fost conceput. Problema este că avem tendința de a continua să-l folosim peste punctul în care abstracțiile sale se potrivesc cu problema, deoarece alternativa necesită un mod complet diferit de a gândi formele. Acest articol este despre această alternativă. Pentru a arăta acest lucru, vom construi exact același formular în mai mulți pași de două ori:

Cu React Hook Form + Zod conectat la React Query pentru trimitere, Cu SurveyJS, care tratează un formular ca date - o simplă schemă JSON - mai degrabă decât un arbore de componente.

Aceleași cerințe, aceeași logică condiționată, același apel API la sfârșit. Apoi vom mapa exact ce s-a mutat și ce a rămas și vom stabili o modalitate practică de a decide ce model ar trebui să utilizați și când. Formularul pe care îl construim:

Acest formular va folosi un flux în 4 pași: Pasul 1: Detalii

Prenume (obligatoriu), E-mail (obligatoriu, format valid).

Pasul 2: Comanda

pret unitar, cantitate, Cota de impozitare, Derivat: Subtotal, impozit, Total.

Pasul 3: Cont și feedback

Ai un cont? (Da/Nu) Dacă da → nume de utilizator + parolă, ambele sunt necesare. Dacă Nu → e-mail deja colectat la pasul 1.

Evaluare de satisfacție (1–5) Dacă ≥ 4 → întreabă „Ce ți-a plăcut?” Dacă ≤ 2 → întrebați „Ce putem îmbunătăți?”

Pasul 4: revizuire

Apare numai dacă total >= 100 Depunerea finală.

Acest lucru nu este extrem. Dar este suficient pentru a expune diferențele arhitecturale. Partea 1: Acționat de componente (Formă React Hook + Zod) Instalare npm instalează react-hook-form zod @hookform/resolvers @tanstack/react-query

Schema Zod Să începem cu schema Zod, pentru că acolo se stabilește de obicei forma formei. Pentru primii doi pași — detalii personale și comenzi de intrare — totul este simplu: șiruri necesare, numere cu minime și o enumerare. Partea interesantă începe când încerci să exprimi regulile condiționale.

import { z } din „zod”;

export const formSchema = z.object({ firstName: z.string().min(1, "Obligatori"), email: z.string().email("E-mail nevalid"), pret: z.number().min(0), cantitate: z.number().min(1), taxRate: z.number(), hasAccount: "["]), username:", z.string().optional(), parola: z.string().optional(), satisfacție: z.number().min(1).max(5), positiveFeedback: z.string().optional(), improvementFeedback: z.string().optional(),}).superRefine((data, ctx) => { if (data, ctx) => { if (data, ctx) => {) === "YAccount. ctx.addIssue({ cod: "personalizat", cale: ["nume utilizator"], mesaj: "Necesar" }); } if (!data.password || data.password.length < 6) { ctx.addIssue({ cod: "personalizat", cale: ["parolă"], mesaj: "Min 6 caractere}"});

if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ cod: "custom", cale: ["positiveFeedback"], mesaj: "Vă rugăm să distribuiți ceea ce v-a plăcut" }); }

dacă (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ cod: „personalizat”, cale:[„improvementFeedback”], mesaj: „Vă rugăm să ne spuneți ce să îmbunătățim” }); }});

tip de export FormData = z.infer;

Observați că numele de utilizator și parola sunt introduse ca opțional() chiar dacă sunt necesare condiționat, deoarece schema la nivel de tip a lui Zod descrie forma obiectului, nu regulile care guvernează când contează câmpurile. Cerința condiționată trebuie să trăiască în interiorul superRefine, care rulează după validarea formei și are acces la obiectul complet. Acea separare nu este un defect; tocmai pentru asta este proiectat instrumentul: superRefine este locul în care merge logica cross-field atunci când nu poate fi exprimată în structura schemei în sine. Ceea ce este de asemenea notabil aici este ceea ce această schemă nu exprimă. Nu are nici un concept de pagini, nici un concept despre câmpurile care sunt vizibile în ce moment și nici un concept de navigare. Toate acestea vor trăi în altă parte. Componenta formularului

import { useForm, useWatch } din „react-hook-form”;import { zodResolver } din „@hookform/resolvers/zod”;import { useMutation } din „@tanstack/react-query”;import { useState, useMemo } din „react”;import { formSchema, tip „Form./schema”;

const PASI = ["detalii", "comanda", "cont", "revizuire"];

tip OrderPayload = FormData & { subtotal: număr; taxă: număr; total: număr };

funcția de export RHFMultiStepForm() { const [pas, setStep] = useState(0);

const mutation = useMutation({ mutationFn: asincron (sarcină utilă: OrderPayload) => { const res = așteaptă preluarea ("/api/comenzi", { metoda: "POST", anteturi: { "Content-Type": "application/json" }, body: JSON.stringify(sarcină utilă), }); if (!res.ok) throw new Error("Eșuat la trimitere"); return 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({ control, nume: "preț" }); const cantitate = useWatch({ control, nume: "cantitate" }); const taxRate = useWatch({ control, nume: "taxRate" }); const areAccount = useWatch({ control, nume: "hasAccount" }); const satisfaction = useWatch({ control, name: "satisfaction" }); const subtotal = useMemo(() => (preț ?? 0) * (cantitate ?? 1), [preț, cantitate]); const tax = useMemo(() => subtotal * (taxRate ?? 0), [subtotal, taxRate]); const total = useMemo(() => subtotal + tax, [subtotal, tax]); const onSubmit = (date: FormData) => mutation.mutate({ ...date, subtotal, tax, total }); const showSubmit = (pas === 2 && total < 100) || (pas === 3 && total >= 100)

return (

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

{pas === 1 && ( <> : < trueAsNumber } value="0.05">5%

Subtotal: {subtotal}
Taxă: {tax}
Total: {total}
)}

{pas === 2 && ( <>

{hasAccount === „Da” && ( <> )}

{satisfacție >= 4 && (