Спонсором цієї статті є 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
return (
);}Перегляньте Pen SurveyJS-03-RHF [розгалужений] від sixthextinction. Тут відбувається досить багато, і варто сповільнитися, щоб помітити, чим усе закінчилося.
Похідні значення — проміжний підсумок, податок, загальний — обчислюються в компоненті за допомогою useWatch і useMemo, оскільки вони залежать від поточних значень полів і для них немає іншого природного місця. Правила видимості для імені користувача, пароля, позитивного відгуку та покращення відгуку живуть у JSX як вбудовані умови. Логіка пропуску кроків — сторінка перегляду з’являється лише тоді, коли загальна кількість >= 100 — вбудована в змінну showSubmit і умову візуалізації на кроці 3. Сама навігація — це лише лічильник useState, який ми збільшуємо вручну. React Query обробляє повторні спроби, кешування та визнання недійсними. Форма просто викликає mutation.mutate з перевіреними даними.
Ніщо з цього не є неправильним, як таке. Це все ще ідіоматичний React, і компонент досить продуктивний завдяки тому, як RHF ізолює повторне рендеринг. Але якби ви передали це комусь, хто цього не писав, і попросили їх пояснити, за яких умов з’являється сторінка огляду, вони повинні були б простежити через showSubmit, умову візуалізації кроку 3 і логіку кнопки навігації — три окремі місця — щоб реконструювати правило, яке могло б бути викладене в одному рядку. Форма працює, так, але поведінку насправді не можна перевірити як систему. Його потрібно виконувати подумки. Що ще важливіше, зміна потребує участі інженерів. Навіть невелике налаштування, як-от коригування, коли з’являється крок перегляду, означає редагування компонента, оновлення перевірки, відкриття запиту на отримання, очікування перегляду та повторне розгортання. Частина 2: Керована схемою (SurveyJS) Тепер давайте створимо той самий потік за допомогою схеми. монтаж npm інсталювати survey-core survey-react-ui @tanstack/react-query
survey-coreНезалежний від платформи механізм виконання, ліцензований Массачусетським технологічним інститутом, який забезпечує рендеринг форм SurveyJS — частина, про яку ми тут дбаємо. Він бере схему JSON, будує з неї внутрішню модель і обробляє все, що інакше було б у вашому компоненті React: оцінює вирази видимості, обчислює похідні значення, керує станом сторінки, відстежує перевірку та вирішує, що означає «завершено», враховуючи, які сторінки насправді було показано.
survey-react-uiРівень інтерфейсу користувача / візуалізації, який з’єднує цю модель з React. По суті, це компонент
Разом вони дають вам повнофункціональну багатосторінкову форму виконання без написання жодного рядка потоку керування. Сам формат схеми, як було сказано раніше, лише JSON — без DSL чи чогось приватного. Ви можете вбудувати його, імпортувати з файлу, отримати з API або зберегти в стовпці бази даних і гідратувати під час виконання. Та сама форма, що й дані Ось та сама форма, цього разу виражена як об’єкт JSON. Схема визначає все: структуру, перевірку, правила видимості, похідні обчислення, навігацію сторінкою — і передає це моделі, яка оцінює це під час виконання. Ось як це виглядає повністю:
export const surveySchema = { title: "Order Flow", showProgressBar: "top", pages: [ { name: "details", elements: [ { type: "text", name: "firstName", isRequired: true }, { type: "text", name: "email", inputType: "email", isRequired: true, validators: [{ type: "email", text: "Invalid email" }] } ] }, { name: "замовлення", елементи: [ { type: "text", name: "price", inputType: "number", defaultValue: 0 }, { type: "text", name: "quantity", inputType: "number", defaultValue: 1 }, { type: "dropdown",name: "taxRate", defaultValue: 0.1, options: [ { value: 0.05, text: "5%" }, { value: 0.1, text: "10%" }, { value: 0.15, text: "15%" } ] }, { type: "expression", name: "subtotal", expression: "{price} {quantity}" }, { type: "вираз", назва: "податок", вираз: "{subtotal} {taxRate}" }, { type: "expression", name: "total", express: "{subtotal} + {tax}" } ] }, { name: "account", elements: [ { type: "radiogroup", name: "hasAccount", options: ["Yes", "No"] }, { type: "text", name: "username", visibleIf: "{hasAccount} = 'Yes'", isRequired: true }, { type: "text", name: "password", inputType: "password", visibleIf: "{hasAccount} = 'Yes'", isRequired: true, validators: [{ type: "text", minLength: 6, text: "Min 6 characters" }] }, { type: "рейтинг", ім'я: "задоволення", rateMin: 1, rateMax: 5 }, { type: "comment", name: "positiveFeedback", visibleIf: "{satisfaction} >= 4" }, { type: "comment", name: "improvementFeedback", visibleIf: "{satisfaction} <= 2" } ] }, { name: "review", visibleIf: "{total} >= 100", елементи: [] } ]};
Порівняйте це з версією RHF.
Блок superRefine, який умовно вимагав ім’я користувача та пароль, зник. visibleIf: "{hasAccount} = 'Yes'" у поєднанні з isRequired: true обробляє обидва проблеми разом у самому полі, де ви очікуєте їх знайти. Ланцюжок useWatch + useMemo, який обчислював проміжний підсумок, податок і загальну суму, замінено трьома полями виразів, які посилаються одне на одного за назвою. Стан сторінки перегляду, який у версії RHF можна було відновити лише шляхом відстеження через showSubmit, гілку візуалізації кроку 3. І, нарешті, логіка кнопки навігації — це єдина властивість visibleIf об’єкта сторінки.
Така сама логіка. Справа в тому, що схема дає йому місце для життя, де воно видно ізольовано, а не поширене по компоненту. Також зауважте, що схема використовує type: 'expression' для проміжних підсумків, податків і підсумків. Вираз призначений лише для читання та використовується переважно для відображення обчислених значень. SurveyJS також підтримує type: '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));
const mutation = useMutation({ mutationFn: async (дані) => { 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 mutationRef = useRef(мутація); mutationRef.current = мутація; useEffect(() => { const handler = (відправник) => mutationRef.current.mutate(sender.data); model.onComplete.add(обробник); return () => model.onComplete.remove(обробник); }, [модель]); // ref уникає повторної реєстрації обробника кожного рендеру (зміни ідентичності об’єкта мутації)
повернення (
<>
Перегляньте Pen SurveyJS-03-SurveyJS [forked] від sixthextinction.
onComplete запускається, коли користувач досягає кінця останньої видимої сторінки. Отже, якщо загальна кількість ніколи не перевищує 100 і сторінка огляду пропускається, вона все одно запускається правильно, оскільки SurveyJS оцінює видимість, перш ніж вирішити, що означає «остання сторінка». Потім sender.data містить усі відповіді разом із обчисленими значеннями (проміжний підсумок, податок, загальна сума) як поля першого класу, тому корисне навантаження API ідентичне тому, що версія RHF зібрана вручну в onSubmit. TheШаблон mutationRef — це той самий шаблон, до якого ви б дотягнулися будь-коли, коли вам потрібен стабільний обробник подій над значенням, яке змінюється під час кожного рендеру — нічого особливого для SurveyJS.
Компонент React більше не містить жодної бізнес-логіки. Немає useWatch, немає умовного JSX, немає лічильника кроків, немає ланцюжка useMemo, немає superRefine. React робить те, у чому він насправді хороший: рендерить компонент і підключає його до виклику API. Що вийшло з React?
Занепокоєння РВЧ стек SurveyJS Видимість Гілки JSX видимийЯкщо Похідні значення useWatch / useMemo вираз Правила крос-поля superRefine Умови схеми Навігація стан кроку Сторінка видима, якщо Розташування правила Розподіляється між файлами Централізований у схемі
Що залишається в React, так це макет, стиль, підключення та інтеграція додатків, тобто те, для чого React насправді створений. Усе інше переміщено до схеми, і оскільки схема є лише об’єктом JSON, її можна зберігати в базі даних, керувати версіями незалежно від коду програми або редагувати за допомогою внутрішніх інструментів без необхідності розгортання. Менеджер продукту, якому потрібно змінити порогове значення, яке запускає сторінку огляду, може зробити це, не торкаючись компонента. Це суттєва операційна відмінність для команд, де поведінка форми часто змінюється і не завжди керується інженерами. Коли використовувати кожен підхід? Ось гарне емпіричне правило, яке працює для мене: уявіть, що ви повністю видалили форму. Що б ти втратив?
Якщо це екрани, вам потрібні форми, керовані компонентами. Якщо це бізнес-логіка, як-от порогові значення, правила розгалуження та умовні вимоги, які кодують реальні рішення, вам потрібен механізм схеми.
Подібним чином, якщо зміни стосуються здебільшого міток, полів і макета, RHF вам добре послужить. Якщо вони стосуються умов, результатів і правил, які вашій операційній або юридичній команді може знадобитися скоригувати у вівторок вдень без подання заяви, модель схеми з SurveyJS є більш чесною. Ці два підходи насправді не конкурують один з одним. Вони стосуються різних класів проблем, і помилка, якої варто уникати, полягає в тому, що абстракція не відповідає вазі логіки — розглядати систему правил як компонент, тому що це знайомий інструмент, або шукати механізм політики, оскільки форма розросла до трьох кроків і отримала умовне поле. Форма, яку ми створили тут, навмисно розташована поблизу межі, досить складна, щоб виявити різницю, але не настільки екстремальна, щоб порівняння здавалося сфальсифікованим. Більшість реальних форм, які стали громіздкими у вашій кодовій базі, ймовірно, розташовані поблизу тієї самої межі, і зазвичай питання полягає лише в тому, чи хтось назвав, чим вони є насправді. Використовуйте React Hook Form + Zod, коли:
Форми CRUD-орієнтовані; Логіка поверхнева та керована інтерфейсом користувача; Інженери володіють усією поведінкою; Бекенд залишається джерелом правди.
Використовуйте SurveyJS, коли:
Форми кодують бізнес-рішення; Правила розвиваються незалежно від інтерфейсу користувача; Логіка має бути видимою, доступною для перевірки або версії; Неінженери впливають на поведінку; Одна і та сама форма має працювати в кількох інтерфейсах.