Эта статья спонсируется 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
return (
);}См. Pen SurveyJS-03-RHF [разветвленный] от Sixthextinction. Здесь происходит довольно много всего, и стоит притормозить, чтобы заметить, чем все закончилось.
Производные значения — промежуточный итог, налог, итог — вычисляются в компоненте с помощью useWatch и useMemo, поскольку они зависят от значений действующих полей и для них нет другого естественного места. Правила видимости для имени пользователя, пароля, позитивной обратной связи и улучшения обратной связи существуют в JSX как встроенные условия. Логика пропуска шагов — страница обзора появляется только при общем значении >= 100 — встроена в переменную showSubmit и условие рендеринга на шаге 3. Сама навигация — это всего лишь счетчик useState, который мы увеличиваем вручную. React Query обрабатывает повторные попытки, кеширование и аннулирование. Форма просто вызываетmutate.mutate с проверенными данными.
Все это само по себе не является неправильным. Это по-прежнему идиоматический React, и компонент довольно производительен благодаря тому, как RHF изолирует повторный рендеринг. Но если бы вы передали это тому, кто этого не писал, и попросили бы его объяснить, при каких условиях появляется страница обзора, ему пришлось бы проследить через showSubmit, условие рендеринга шага 3 и логику кнопки навигации — три отдельных места — чтобы восстановить правило, которое можно было бы сформулировать в одной строке. Да, форма работает, но поведение как системы на самом деле не поддается проверке. Это нужно выполнять мысленно. Что еще более важно, его изменение требует участия инженеров. Даже небольшая настройка, например настройка времени появления этапа проверки, означает редактирование компонента, обновление проверки, открытие запроса на включение, ожидание проверки и повторное развертывание. Часть 2. Управление схемой (SurveyJS) Теперь давайте построим тот же поток, используя схему. Установка npm install Survey-Core Survey-React-UI @tanstack/React-Query
Survey-CoreНезависимый от платформы механизм выполнения, лицензированный MIT, который обеспечивает рендеринг форм SurveyJS — та часть, которая нас здесь волнует. Он берет схему JSON, строит на ее основе внутреннюю модель и обрабатывает все, что в противном случае было бы в вашем компоненте React: оценку выражений видимости, вычисление производных значений, управление состоянием страницы, отслеживание проверки и принятие решения о том, что означает «завершенность», учитывая, какие страницы были фактически показаны.
Survey-React-uiСлой пользовательского интерфейса/рендеринга, который соединяет эту модель с React. По сути, это компонент
Вместе они дают вам полнофункциональную среду выполнения многостраничных форм без написания единой строки потока управления. Сам формат схемы, как было сказано ранее, представляет собой просто JSON — никакого DSL или чего-то проприетарного. Вы можете встроить его, импортировать из файла, получить из API или сохранить в столбце базы данных и увлажнить во время выполнения. Та же форма, что и данные Вот та же форма, на этот раз выраженная в виде объекта JSON. Схема определяет все: структуру, проверку, правила видимости, производные вычисления, навигацию по страницам — и передает это модели, которая оценивает ее во время выполнения. Вот как это выглядит полностью:
Export const SurveySchema = { title: "Поток заказов", showProgressBar: "top", страницы: [ { name: "details", elements: [ { type: "text", name: "firstName", isRequired: true }, { type: "text", name: "email", inputType: "email", isRequired: true, validators: [{ type: "email", text: "Invalid email" }] } ] }, { name: "order", elements: [ { type: "text", name: "price", inputType: "number", defaultValue: 0 }, { type: "text", name: "quantity", inputType: "number", defaultValue: 1 }, { type: "dropdown",name: "taxRate", defaultValue: 0,1, варианты: [ { значение: 0,05, текст: "5%" }, { значение: 0,1, текст: "10%" }, { значение: 0,15, текст: "15%" } ] }, { тип: "выражение", имя: "промежуточный итог", выражение: "{цена} {количество}" }, { тип: "выражение", name: "tax", выражение: "{subtotal} {taxRate}" }, { type: "expression", name: "total", выражение: "{subtotal} + {tax}" } ] }, { name: "account", elements: [ { type: "radiogroup", name: "hasAccount", выбор: ["Да", "Нет"] }, { type: "text", name: "username",visibleIf: "{hasAccount} = 'Да'", isRequired: true }, { type: "text", name: "password", inputType: "password",visibleIf: "{hasAccount} = 'Да'", isRequired: true, валидаторы: [{ type: "text", minLength: 6, text: "Min 6 символов" }] }, { type: "rating", name: "satisfaction",rateMin: 1, rateMax: 5 }, { type: "comment", name: "positiveFeedback",visibleIf: "{satisfaction} >= 4" }, { type: "comment", name: "improvementFeedback",visibleIf: "{satisfaction} <= 2" } ] }, { name: "review",visibleIf: "{total} >= 100", elements: [] } ]};
Сравните это на минутку с версией RHF.
Блок superRefine, который условно требовал имя пользователя и пароль, исчез. видимыйЕсли: "{hasAccount} = 'Да'" в сочетании с isRequired: true обрабатывает обе проблемы вместе, в самом поле, где вы ожидаете их найти. Цепочка useWatch + useMemo, вычисляющая промежуточный итог, налог и итог, заменяется тремя полями выражений, которые ссылаются друг на друга по имени. Состояние страницы обзора, которое в версии RHF можно было восстановить только путем отслеживания через showSubmit, ветвь рендеринга шага 3. И, наконец, логика кнопки навигации — это единственное свойствоvisibleIf объекта страницы.
Там та же логика. Просто схема дает ему место для жизни, где он виден изолированно, а не распределен по компоненту. Также обратите внимание, что в схеме используется тип: «выражение» для промежуточного итога, налога и итога. Выражение доступно только для чтения и используется в основном для отображения вычисленных значений. SurveyJS также поддерживает тип: «html» для статического содержимого, но для расчетных значений правильным выбором является выражение. Теперь что касается стороны React. Рендеринг и представление Очень просто. Подключите onComplete к вашему API таким же образом — через useMutation или обычную выборку:
импортировать { useState, useEffect, useRef } из "react"; импортировать { useMutation } из "@tanstack/react-query"; импортировать { Model } из "survey-core"; импортировать { Survey } из "survey-react-ui"; импортировать "survey-core/survey-core.css";
функция экспорта SurveyForm() { const [model] = useState(() => new Model(surveySchema));
constmutation = useMutation({ mutationFn: асинхронный (данные) => { const res = await fetch("/api/orders", { метод: «ПОСТ», заголовки: { "Content-Type": "application/json" }, тело: JSON.stringify(данные), }); if (!res.ok) выдать новую ошибку («Не удалось отправить»); вернуть res.json(); }, });
constmutationRef = useRef(мутация); мутацияRef.current = мутация; useEffect(() => { const handler = (отправитель) => мутацияRef.current.mutate(sender.data); model.onComplete.add(handler); return () => model.onComplete.remove(handler); }, [model]); // ref позволяет избежать перерегистрации обработчика при каждом рендеринге (изменение идентификатора объекта мутации)
возврат (
<>
См. Pen SurveyJS-03-SurveyJS [разветвленный] от Sixthextinction.
onComplete срабатывает, когда пользователь достигает конца последней видимой страницы. Таким образом, если общее количество никогда не превышает 100 и страница обзора пропускается, она все равно срабатывает правильно, поскольку SurveyJS оценивает видимость, прежде чем решить, что означает «последняя страница». Затем sender.data содержит все ответы вместе с рассчитанными значениями (промежуточный итог, налог, итог) в виде полей первого класса, поэтому полезная нагрузка API идентична той, которую версия RHF собирала вручную в onSubmit. ШаблонmutationRef — это тот же самый шаблон, который вы можете использовать везде, где вам нужен стабильный обработчик событий для значения, которое меняется при каждом рендеринге — в этом нет ничего специфичного для SurveyJS.
Компонент React больше не содержит никакой бизнес-логики. Здесь нет useWatch, нет условного JSX, нет счетчика шагов, нет цепочки useMemo, нет superRefine. React делает то, что у него действительно хорошо получается: рендерит компонент и связывает его с вызовом API. Что вышло из React?
Беспокойство Стек РВЧ ОбзорJS Видимость JSX-ветви видимыйЕсли Производные значения использоватьWatch/useMemo выражение Межполевые правила суперуточнение Условия схемы Навигация состояние шага Страница виднаЕсли Местоположение правила Распределено по файлам Централизовано в схеме
Что остается в React, так это макет, стиль, подключение отправки и интеграция приложений, то есть то, для чего React на самом деле предназначен. Все остальное перенесено в схему, а поскольку схема представляет собой всего лишь объект JSON, ее можно хранить в базе данных, создавать версии независимо от кода вашего приложения или редактировать с помощью внутренних инструментов без необходимости развертывания. Менеджер по продукту, которому необходимо изменить порог, запускающий страницу обзора, может сделать это, не касаясь компонента. Это существенное отличие для команд, где поведение формы часто меняется и не всегда определяется инженерами. Когда использовать каждый подход? Вот хорошее практическое правило, которое работает для меня: представьте, что вы полностью удаляете форму. Что бы вы потеряли?
Если это экраны, вам нужны формы, управляемые компонентами. Если бизнес-логика, такая как пороговые значения, правила ветвления и условные требования, которые кодируют реальные решения, вам нужен механизм схемы.
Точно так же, если предстоящие изменения в основном касаются меток, полей и макета, RHF вам подойдет. Если речь идет об условиях, результатах и правилах, которые вашему оператору или юрисконсульту, возможно, придется скорректировать во вторник днем без подачи заявки, модель схемы с SurveyJS подойдет более честно. Эти два подхода на самом деле не конкурируют друг с другом. Они решают разные классы проблем, и ошибка, которую следует избегать, — это несоответствие абстракции весу логики — обращение с системой правил как с компонентом, потому что это знакомый инструмент, или обращение к механизму политики, потому что форма выросла до трех шагов и приобрела условное поле. Форма, которую мы здесь создали, намеренно расположена вблизи границы, достаточно сложная, чтобы выявить разницу, но не настолько экстремальная, чтобы сравнение казалось некорректным. Большинство реальных форм, которые стали громоздкими в вашей кодовой базе, вероятно, находятся рядом с той же границей, и вопрос обычно заключается в том, назвал ли кто-нибудь то, чем они на самом деле являются. Используйте React Hook Form + Zod, когда:
Формы ориентированы на CRUD; Логика поверхностна и управляется пользовательским интерфейсом; Инженеры владеют всем поведением; Бэкэнд остается источником истины.
Используйте SurveyJS, когда:
Формы кодируют бизнес-решения; Правила развиваются независимо от пользовательского интерфейса; Логика должна быть видимой, проверяемой или версионной; Неинженеры влияют на поведение; Одна и та же форма должна работать в нескольких интерфейсах.