Эта статья спонсируется SurveyJS. Существует ментальная модель, которую разделяют большинство разработчиков React, даже не обсуждая ее вслух. Формы всегда должны быть компонентами. Это означает стек типа:

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

И для подавляющего большинства форм — ваших экранов входа в систему, ваших страниц настроек, ваших модальных окон CRUD — это работает очень хорошо. Каждая часть выполняет свою работу, они четко скомпонованы, и вы можете перейти к частям вашего приложения, которые действительно отличают ваш продукт. Но время от времени форма начинает накапливать такие вещи, как правила видимости, которые зависят от предыдущих ответов, или производные значения, которые каскадно проходят через три поля. Возможно, даже целые страницы, которые следует пропустить или показать на основе промежуточного итога. Вы обрабатываете первое условие с помощью useWatch и встроенной ветки, и это нормально. Потом еще один. Затем вы используете superRefine для кодирования межполевых правил, которые ваша схема Zod не может выразить обычным способом. Затем пошаговая навигация начинает терять бизнес-логику. В какой-то момент вы смотрите на то, что создали, и понимаете, что форма больше не является пользовательским интерфейсом. Это скорее процесс принятия решений, и дерево компонентов — это именно то место, где вы его сохранили. Я думаю, что именно здесь ментальная модель форм в React дает сбой, и на самом деле в этом нет ничьей вины. Стек RHF + Zod превосходно справляется со своей задачей. Проблема в том, что мы склонны продолжать использовать его после того момента, когда его абстракции соответствуют проблеме, потому что альтернатива требует совершенно иного подхода к формам. Эта статья об этой альтернативе. Чтобы продемонстрировать это, мы дважды создадим одну и ту же многошаговую форму:

С React Hook Form + Zod, подключенным к React Query для отправки, С помощью SurveyJS, который рассматривает форму как данные — простую схему JSON — а не дерево компонентов.

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

Эта форма будет использовать четырехэтапный процесс: Шаг 1: Детали

Имя (обязательно), Электронная почта (обязательно, действительный формат).

Шаг 2: Заказ

Цена за единицу, Количество, Налоговая ставка, Производное: Итого, Налог, Итого.

Шаг 3: Аккаунт и обратная связь

У вас есть учетная запись? (Да/Нет) Если да → имя пользователя + пароль, оба обязательны. Если нет → электронная почта уже собрана на шаге 1.

Рейтинг удовлетворенности (1–5) Если ≥ 4 → спросите: «Что вам понравилось?» Если ≤ 2 → спросите: «Что мы можем улучшить?»

Шаг 4: Обзор

Появляется только если всего >= 100 Окончательная подача.

Это не крайность. Но этого достаточно, чтобы выявить архитектурные различия. Часть 1. Управление компонентами (форма React Hook + Zod) Установка npm install response-hook-form zod @hookform/resolvers @tanstack/react-query

Схема Зода Начнем со схемы Зода, потому что именно там обычно устанавливается форма формы. На первых двух шагах — личные данные и ввод заказа — все просто: необходимые строки, числа с минимальными значениями и перечисление. Самое интересное начинается, когда вы пытаетесь выразить условные правила.

импортировать { z } из "zod";

Export const formSchema = z.object({ firstName: z.string().min(1, "Обязательно"), электронная почта: z.string().email("Неверный адрес электронной почты"), цена: z.number().min(0), количество: z.number().min(1), TaxRate: z.number(), hasAccount: z.enum(["Да", "Нет"]), имя пользователя: z.string().optional(), пароль: z.string().optional(), удовлетворение: z.number().min(1).max(5), позитивная обратная связь: z.string().optional(), обратная связь улучшения: z.string().optional(),}).superRefine((data, ctx) => { if (data.hasAccount === "Да") { if (!data.username) { ctx.addIssue({ code: "custom", путь: ["имя пользователя"], сообщение: "Требуется" }); } if (!data.password || data.password.length < 6) { ctx.addIssue({ код: "custom", путь: ["пароль"], сообщение: "Минимум 6 символов" } });

if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ code: "custom", path: ["positiveFeedback"], сообщение: "Пожалуйста, поделитесь тем, что вам понравилось" }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ code: "custom", путь:["improvementFeedback"], сообщение: "Пожалуйста, расскажите нам, что улучшить" }); }});

тип экспорта FormData = z.infer;

Обратите внимание, что имя пользователя и пароль вводятся как необязательные(), хотя они необходимы условно, поскольку схема уровня типа Zod описывает форму объекта, а не правила, определяющие, когда поля имеют значение. Условное требование должно находиться внутри superRefine, который запускается после проверки формы и имеет доступ ко всему объекту. Это разделение не является недостатком; это именно то, для чего предназначен этот инструмент: superRefine — это то место, где применяется межполевая логика, когда ее невозможно выразить в самой структуре схемы. Здесь также примечательно то, чего эта схема не выражает. В нем нет понятия страниц, понятия того, какие поля в какой точке видны, и понятия навигации. Все это будет жить где-то еще. Компонент формы

импортировать { useForm, useWatch } из "react-hook-form"; импортировать { zodResolver } из "@hookform/resolvers/zod"; импортировать { useMutation } из "@tanstack/react-query"; импортировать { useState, useMemo } из "реагировать"; импортировать { formSchema, тип FormData } из "./schema";

const STEPS = ["подробности", "заказ", "аккаунт", "отзыв"];

введите OrderPayload = FormData & {промежуточный итог: число; налог: номер; итого: число };

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

constmutation = useMutation({ mutationFn: async (полезная нагрузка: OrderPayload) => { const res = await fetch("/api/orders", { метод: «ПОСТ», заголовки: { "Content-Type": "application/json" }, тело: JSON.stringify(полезная нагрузка), }); if (!res.ok) выдать новую ошибку («Не удалось отправить»); вернуть res.json(); }, });

const {register, control, handleSubmit, formState: { error }, } = useForm({solver: zodResolver(formSchema), defaultValues: {цена: 0, количество: 1, TaxRate: 0,1, удовлетворение: 3, hasAccount: "Нет", }, }); const цена = useWatch({control, name: "price" }); const количество = useWatch({control, name: "количество" }); const TaxRate = useWatch({control, name: "taxRate" }); const hasAccount = useWatch({control, name: "hasAccount" }); const удовлетворение = useWatch({control, name: "удовлетворение" }); const subtotal = useMemo(() => (цена?? 0) * (количество?? 1), [цена, количество]); const Tax = useMemo(() => промежуточный итог * (taxRate ?? 0), [промежуточный итог, TaxRate]); const total = useMemo(() => промежуточный итог + налог, [промежуточный итог, налог]); const onSubmit = (data: FormData) =>mutate.mutate({ ...data, промежуточный итог, налог, итог }); const showSubmit = (шаг === 2 && всего < 100) || (шаг === 3 && всего >= 100)

return (

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

{step === 1 && ( <>

Промежуточный итог: {подитог
Налог: {tax}
Итог: {total
)}

{step === 2 && ( <>

{hasAccount === "Да" && ( <> )}

{удовлетворение >= 4 && (