Спонсором цієї статті є SurveyJS Існує ментальна модель, яку поділяють більшість розробників React, навіть не обговорюючи її вголос. Що форми завжди мають бути компонентами. Це означає такий стек:

React Hook Form для локального стану (мінімальна повторна візуалізація, ергономічна реєстрація полів, імперативна взаємодія). Zod для перевірки (коректність введення, перевірка меж, безпечний аналіз типу). React Query для серверної частини: надсилання, повторні спроби, кешування, синхронізація сервера тощо.

І для переважної більшості форм — ваших екранів входу, ваших сторінок налаштувань, ваших CRUD-модалей — це працює дуже добре. Кожна деталь виконує свою роботу, вони чітко комбінуються, і ви можете переходити до частин вашої програми, які насправді відрізняють ваш продукт. Але час від часу форма починає накопичувати такі речі, як правила видимості, які залежать від попередніх відповідей, або похідні значення, які каскадно розподіляються через три поля. Можливо, навіть цілі сторінки, які слід пропустити або показати на основі поточної суми. Ви обробляєте першу умову за допомогою useWatch і вбудованої гілки, що добре. Потім ще один. Тоді ви використовуєте superRefine для кодування правил перехресних полів, які ваша схема Zod не може виразити звичайним способом. Потім покрокова навігація починає витікати з бізнес-логіки. У якийсь момент ви дивитесь на те, що створили, і розумієте, що форма більше не є інтерфейсом користувача. Це скоріше процес прийняття рішень, і дерево компонентів – це саме те місце, де ви його зберігали. Саме тут, на мою думку, руйнується ментальна модель для форм у React, і в цьому насправді ніхто не винен. Стек RHF + Zod чудовий у тому, для чого він був розроблений. Проблема в тому, що ми маємо тенденцію продовжувати використовувати його після моменту, коли його абстракції відповідають проблемі, оскільки альтернатива вимагає зовсім іншого способу мислення про форми. Ця стаття про цю альтернативу. Щоб показати це, ми двічі створимо ту саму багатоетапну форму:

З React Hook Form + Zod підключено до React Query для надсилання, З SurveyJS, який розглядає форму як дані — просту схему JSON — а не як дерево компонентів.

Ті самі вимоги, та ж умовна логіка, той самий виклик API наприкінці. Тоді ми точно відобразимо, що перемістилося, а що залишилося, і запропонуємо практичний спосіб вирішити, яку модель вам слід використовувати та коли. Форма, яку ми створюємо:

Ця форма використовуватиме 4-етапний процес: Крок 1: Деталі

Ім'я (обов'язково), Електронна пошта (обов’язково, дійсний формат).

Крок 2: замовлення

Ціна за одиницю, кількість, Ставка податку, Виведено: Проміжний підсумок, податок, Всього.

Крок 3: обліковий запис і відгук

У вас є обліковий запис? (Так/Ні) Якщо так → ім'я користувача + пароль, потрібно обидва. Якщо ні → електронна пошта вже зібрана на кроці 1.

Оцінка задоволеності (1–5) Якщо ≥ 4 → запитайте «Що вам сподобалося?» Якщо ≤ 2 → запитайте «Що ми можемо покращити?»

Крок 4: огляд

З’являється, лише якщо загальна сума >= 100 Остаточне подання.

Це не крайність. Але цього достатньо, щоб викрити архітектурні відмінності. Частина 1: Керована компонентами (React Hook Form + Zod) монтаж npm інсталювати форму реагування-гака zod @hookform/resolvers @tanstack/react-query

Зодова схема Почнемо зі схеми Zod, тому що зазвичай саме там формується форма форми. Для перших двох кроків — особистих даних і введення порядку — все просто: необхідні рядки, числа з мінімумами та перерахування. Цікава частина починається, коли ви намагаєтесь виразити умовні правила.

імпорт { z } із "zod";

export const formSchema = z.object({ firstName: z.string().min(1, "Required"), email: z.string().email("Invalid email"), price: z.number().min(0), quantity: z.number().min(1), taxRate: z.number(), hasAccount: z.enum(["Yes", "No"]), username: z.string().optional(), пароль: z.string().optional(), задоволення: z.number().min(1).max(5), positiveFeedback: z.string().optional(), покращенняFeedback: z.string().optional(),}).superRefine((data, ctx) => { if (data.hasAccount === "Yes") { if (!data.username) { ctx.addIssue({ code: "custom", path: ["username"], message: "Required" });

if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ code: "custom", path: ["positiveFeedback"], message: "Будь ласка, поділіться тим, що вам сподобалося" }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ код: "спеціальний", шлях:["improvementFeedback"], повідомлення: "Будь ласка, скажіть нам, що потрібно покращити" }); }});

тип експорту FormData = z.infer;

Зауважте, що ім’я користувача та пароль вводяться як optional(), хоча вони є умовно обов’язковими, оскільки схема рівня типу Zod описує форму об’єкта, а не правила, які регулюють важливість полів. Умовна вимога має жити в superRefine, який запускається після перевірки форми та має доступ до повного об’єкта. Ця розлука не є недоліком; це саме те, для чого розроблений інструмент: superRefine — це те, куди йде логіка між полями, коли її не можна виразити в самій структурі схеми. Тут також примітно те, що ця схема не виражає. Він не має поняття сторінок, поняття про те, які поля в якій точці видно, і поняття навігації. Все це буде жити десь в іншому місці. Компонент форми

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

const STEPS = ["подробиці", "замовлення", "рахунок", "огляд"];

type OrderPayload = FormData & { subtotal: number; податок: кількість; загальна: кількість };

функція експорту RHFMultiStepForm() { const [крок, setStep] = useState(0);

const mutation = useMutation({ mutationFn: async (корисне навантаження: OrderPayload) => { const res = await fetch("/api/orders", { метод: "POST", заголовки: { "Content-Type": "application/json" }, тіло: JSON.stringify(корисне навантаження), }); if (!res.ok) throw new Error("Failed to submit"); повернути 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, name: "price" }); const quantity = useWatch({ control, name: "quantity" }); const taxRate = useWatch({ control, name: "taxRate" }); const hasAccount = useWatch({ елемент керування, назва: "hasAccount" }); const satisfaction = useWatch({ control, name: "satisfaction" }); const subtotal = useMemo(() => (ціна ?? 0) * (кількість ?? 1), [ціна, кількість]); const tax = useMemo(() => subtotal * (taxRate ?? 0), [subtotal, taxRate]); const total = useMemo(() => проміжний підсумок + податок, [проміжний підсумок, податок]); const onSubmit = (data: FormData) => mutation.mutate({ ...data, subtotal, tax, total }); const showSubmit = (крок === 2 && total < 100) || (крок === 3 && всього >= 100)

return (

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

{крок === 1 && ( <> <вибрати {...register("taxRate", { valueAsNumber: true })}> <параметр value="0.05">5%

Проміжна сума: {subtotal}
Податок: {tax}
Усього: {total}
)}

{step === 2 && ( <>

{hasAccount === "Так" && ( <> )}

{задоволення >= 4 && (