Ten artykuł jest sponsorowany przez SurveyJS Istnieje model mentalny, którym podziela się większość programistów React, nigdy nie omawiając go na głos. Że formy zawsze mają być komponentami. Oznacza to stos taki jak:

Reaguj na formularz Hook dla stanu lokalnego (minimalne ponowne renderowanie, ergonomiczna rejestracja w terenie, konieczna interakcja). Zod do walidacji (poprawność danych wejściowych, walidacja granic, parsowanie bezpieczne dla typu). Reaguj na zapytanie dotyczące zaplecza: przesyłanie, ponowne próby, buforowanie, synchronizacja serwera i tak dalej.

W przypadku zdecydowanej większości formularzy – ekranów logowania, stron ustawień, modułów CRUD – działa to naprawdę dobrze. Każdy element spełnia swoje zadanie, komponuje się przejrzyście i możesz przejść do tych części aplikacji, które faktycznie wyróżniają Twój produkt. Jednak od czasu do czasu w formularzu zaczynają się gromadzić takie elementy, jak reguły widoczności zależne od wcześniejszych odpowiedzi lub wartości pochodne, które przechodzą kaskadą przez trzy pola. Może nawet całe strony, które należy pominąć lub wyświetlić na podstawie sumy bieżącej. Pierwszy warunek obsługujesz za pomocą useWatch i wbudowanej gałęzi, co jest w porządku. Potem kolejny. Następnie sięgasz po superRefine, aby zakodować reguły międzypolowe, których Twój schemat Zoda nie może wyrazić w normalny sposób. Następnie nawigacja krokowa zaczyna przeciekać logikę biznesową. W pewnym momencie patrzysz na to, co zbudowałeś i zdajesz sobie sprawę, że formularz nie jest już tak naprawdę interfejsem użytkownika. To raczej proces decyzyjny, a drzewo komponentów to miejsce, w którym je zapisałeś. Myślę, że w tym miejscu mentalny model formularzy w Reactie się załamuje i tak naprawdę nie jest to niczyja wina. Stos RHF + Zod jest doskonały w tym, do czego został zaprojektowany. Problem w tym, że mamy tendencję do używania go aż do momentu, w którym jego abstrakcje odpowiadają problemowi, ponieważ alternatywa wymaga zupełnie innego sposobu myślenia o formach. Ten artykuł dotyczy tej alternatywy. Aby to pokazać, dwukrotnie zbudujemy dokładnie ten sam wieloetapowy formularz:

Z formularzem React Hook + Zod podłączonym do zapytania React w celu przesłania, Z SurveyJS, który traktuje formularz jako dane — prosty schemat JSON — a nie drzewo komponentów.

Te same wymagania, ta sama logika warunkowa, to samo wywołanie API na końcu. Następnie dokładnie zmapujemy, co się przeniosło, a co pozostało, a także przedstawimy praktyczny sposób decydowania, jakiego modelu należy użyć i kiedy. Formularz, który budujemy:

W tym formularzu zastosowano 4-etapowy proces: Krok 1: Szczegóły

Imię (wymagane), Adres e-mail (wymagany, prawidłowy format).

Krok 2: Zamów

Cena jednostkowa, Ilość, stawka podatku, Pochodne: Suma częściowa, podatek, Razem.

Krok 3: Konto i opinia

Czy masz konto? (Tak/Nie) Jeśli Tak → nazwa użytkownika + hasło, oba wymagane. Jeśli Nie → e-mail już zebrany w kroku 1.

Ocena satysfakcji (1–5) Jeśli ≥ 4 → zapytaj „Co Ci się podobało?” Jeśli ≤ 2 → zapytaj „Co możemy ulepszyć?”

Krok 4: Przegląd

Pojawia się tylko wtedy, gdy suma >= 100 Ostateczne złożenie.

To nie jest ekstremalne. Ale to wystarczy, aby wyeksponować różnice architektoniczne. Część 1: Sterowanie komponentami (forma haka React + Zod) Instalacja npm zainstaluj formularz reakcji-hook zod @hookform/resolvers @tanstack/react-query

Schemat Zoda Zacznijmy od schematu Zoda, bo to tam zazwyczaj ustalany jest kształt formy. W pierwszych dwóch krokach — danych osobowych i danych wejściowych zamówienia — wszystko jest proste: wymagane ciągi znaków, liczby z wartościami minimalnymi i wyliczenie. Interesująca część zaczyna się, gdy próbujesz wyrazić reguły warunkowe.

importuj { z } z "zod";

eksport const formSchema = z.object({ imię: z.string().min(1, "Wymagane"), e-mail: z.string().email("Nieprawidłowy adres e-mail"), cena: z.number().min(0), ilość: z.number().min(1), stawka podatku: z.number(), hasAccount: z.enum(["Tak", "Nie"]), nazwa użytkownika: z.string().opcjonalne(), hasło: z.string().opcjonalne(), satysfakcja: z.number().min(1).max(5), pozytywne sprzężenie zwrotne: z.string().opcjonalne(), ulepszenieOpinia zwrotna: z.string().opcjonalne(),}).superRefine((dane, ctx) => { if (data.hasAccount === "Tak") { if (!data.username) { ctx.addIssue({ kod: "niestandardowe", ścieżka: ["nazwa użytkownika"], wiadomość: "Wymagane" }); if (!data.hasło || data.hasło.długość < 6) { ctx.addIssue({ kod: "niestandardowe", ścieżka: ["hasło"], wiadomość: "Min. 6 znaków" } } }

if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ kod: "custom", ścieżka: ["positiveFeedback"], wiadomość: "Udostępnij, co Ci się podobało" }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ code: "custom", ścieżka:["improvementFeedback"], wiadomość: "Proszę nam powiedzieć, co mamy poprawić" }); }});

typ eksportu FormData = z.infer;

Zwróć uwagę, że nazwa użytkownika i hasło są wpisywane jako opcjonalne(), mimo że są wymagane warunkowo, ponieważ schemat poziomu typu Zoda opisuje kształt obiektu, a nie reguły rządzące tym, kiedy pola mają znaczenie. Warunkowe wymaganie musi znajdować się w superRefine, które jest uruchamiane po sprawdzeniu kształtu i uzyskuje dostęp do całego obiektu. To oddzielenie nie jest wadą; właśnie do tego zaprojektowano to narzędzie: superRefine to miejsce, w którym logika międzypolowa trafia tam, gdzie nie można jej wyrazić w samej strukturze schematu. Godne uwagi jest to, czego ten schemat nie wyraża. Nie ma koncepcji stron, nie ma pojęcia, które pola są widoczne w którym momencie, ani nie ma koncepcji nawigacji. To wszystko będzie żyło gdzie indziej. Komponent formularza

import { useForm, useWatch } z "react-hook-form"; import { zodResolver } z "@hookform/resolvers/zod"; import { useMutation } z "@tanstack/react-query"; import { useState, useMemo } z "react"; import { formSchema, wpisz FormData } z "./schema";

const KROKI = ["szczegóły", "zamówienie", "konto", "recenzja"];

wpisz OrderPayload = FormData & { suma częściowa: liczba; podatek: liczba; łącznie: liczba };

funkcja eksportu RHFMultiStepForm() { const [krok, setStep] = useState(0);

stała mutacja = useMutation({ mutacjaFn: asynchroniczny (ładunek: OrderPayload) => { const res = oczekiwanie na pobranie("/api/zamówienia", { metoda: „POST”, nagłówki: { "Content-Type": "application/json" }, treść: JSON.stringify(ładunek), }); if (!res.ok) wyrzuć nowy błąd("Nie udało się przesłać"); zwróć res.json(); }, });

const { rejestr, kontrola, handleSubmit, formState: { błędy }, } = useForm({ resolver: zodResolver(formSchema), defaultValues: { cena: 0, ilość: 1, stawka podatku: 0,1, satysfakcja: 3, hasAccount: "Nie", }, }); stała cena = useWatch({kontrola, nazwa: "cena" }); const ilość = useWatch({kontrola, nazwa: "ilość" }); const taxRate = useWatch({kontrola, nazwa: "taxRate" }); const hasAccount = useWatch({kontrola, nazwa: "hasAccount" }); const satysfakcja = useWatch({kontrola, nazwa: "satysfakcja" }); const subtotal = useMemo(() => (cena ?? 0) * (ilość ?? 1), [cena, ilość]); const tax = useMemo(() => suma częściowa * (stawka podatku ?? 0), [suma częściowa, stawka podatku]); const total = useMemo(() => suma częściowa + podatek, [suma częściowa, podatek]); const onSubmit = (dane: FormData) => mutacja.mutate({ ...dane, suma częściowa, podatek, suma }); const showSubmit = (krok === 2 && suma < 100) || (krok === 3 i łącznie >= 100)

return (

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

{krok === 1 && ( <>

Suma częściowa: {subtotal
Podatek: {tax
Suma: {total
)}

{krok === 2 && ( <>

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

{satysfakcja >= 4 && (