Многошаговые визарды (Multi-step Wizards) — стандартный паттерн для распределения сложных, когнитивно перегруженных процессов (регистрация бизнеса, онбординг клиентов в банках, оформление кредитных заявок, настройка облачной инфраструктуры) на последовательность логических этапов. Однако по мере роста проекта наивный подход с переключением шагов через тернарные операторы (step === 1 ? <Step1 /> : <Step2 />) неизбежно заводит архитектуру в тупик:
if/else.Для создания надежных промышленных визардов применяются два строгих фундаментальных паттерна: детерминированный конечный автомат (Finite State Machine — FSM) для управления графом навигации и асинхронное персистентное хранилище IndexedDB для непрерывного фонового сохранения черновиков с защитой от блокировки основного потока.
Многошаговая форма должна проектироваться как распределенный конвейер:
При реализации автосохранения форм разработчики часто выбирают window.localStorage. В Enterprise-разработке это считается критическим архитектурным дефектом:
localStorage.setItem() выполняются синхронно в главном потоке (Main Thread / UI Thread). При сериализации объекта формы размером в несколько сотен килобайт или частых вызовах при наборе текста браузер блокирует обработку кадров, приводя к ощутимым задержкам ввода (деградация INP свыше 200 мс).base64 вызовет фатальное исключение DOMException: QuotaExceededError.localStorage нельзя напрямую записать нативные структуры File, Blob или ArrayBuffer — их приходится кодировать в Base64, что увеличивает объем потребляемой памяти на 33%.IndexedDB — полноценная асинхронная транзакционная документная база данных внутри браузера:
File, Blob, Date, Map, Set без строковой сериализации в JSON.В реальных продуктах навигация редко бывает линейной ($1 \rightarrow 2 \rightarrow 3$). Обычно это ориентированный граф с условиями:
INDIVIDUAL $\rightarrow$ шаг PASSPORT_DATA $\rightarrow$ шаг PAYMENT.CORPORATE $\rightarrow$ шаг COMPANY_LEGAL $\rightarrow$ шаг BENEFICIARIES $\rightarrow$ шаг PAYMENT.+-------------------+
| 1. ACCOUNT_TYPE |
+-------------------+
|
+------------------+------------------+
| (type === 'INDIVIDUAL') | (type === 'CORPORATE')
v v
+---------------------+ +---------------------+
| 2A. PASSPORT_DATA | | 2B. COMPANY_LEGAL |
+---------------------+ +---------------------+
| |
| v
| +---------------------+
| | 3B. BENEFICIARIES |
| +---------------------+
| |
+------------------+------------------+
|
v
+-------------------+
| 4. PAYMENT_SETUP |
+-------------------+
|
v
+-------------------+
| 5. REVIEW_SUBMIT |
+-------------------+Конечный автомат описывается тремя компонентами:
NEXT, PREV, GO_TO, RESET.Взаимодействие визарда строится как оркестровка трех систем:
shouldUnregister: false.+-----------------------------------------------------------------------------------+
| USER INTERFACE LAYER |
| |
| [Active Step Component (e.g. Step 2B)] <-----+ |
| | | |
| (User Input) | (Render computed active step) |
| v | |
+----------|------------------------------------|-----------------------------------+
v |
+------------------------------------+ +------------------------------------------+
| REACT HOOK FORM ENGINE | | FINITE STATE MACHINE (FSM) |
| | | |
| - shouldUnregister: false | | Current: COMPANY_LEGAL |
| - Fields values in fieldsRef | | History: [ACCOUNT_TYPE, COMPANY_LEGAL] |
| | | |
| [User Clicks "Next"] | | Transitions: |
| | | | ON 'NEXT': |
| v | | if corporate -> BENEFICIARIES |
| [trigger(['taxId', 'compName'])] | | if individual -> PAYMENT |
| | | +------------------------------------------+
| (Valid?) | ^
| | | |
| +---(Yes: Emit NEXT event)---------------------------+
+------------------------------------+
|
(On Any Change)
v
+-----------------------------------------------------------------------------------+
| INDEXEDDB DRAFT PERSISTENCE WORKER |
| |
| [Debounce Timer: 500ms] |
| | |
| v |
| [idb.transaction('drafts', 'readwrite')] |
| | |
| +---> Save: { id: 'wizard_draft', step: 'COMPANY_LEGAL', data, v: 2 } |
| |
| (Zero Main Thread Blocking, Capacity > 100 MB) |
+-----------------------------------------------------------------------------------+При инициализации визард опрашивает IndexedDB. Если найден валидный черновик текущей версии схемы, RHF восстанавливает данные через reset().
Пользователь заполняет поля активного шага. При попытке перехода вызывается form.trigger(stepFields) для проверки схемы данного шага.
Слушатель формы с дебаунсом 500 мс сохраняет актуальный снимок в транзакционную таблицу drafts базы данных IndexedDB.
Диспетчер автомата вычисляет следующий шаг согласно правилам ветвления и пушит предыдущий шаг в стек истории навигации.
После успешного ответа Server Action черновик в IndexedDB удаляется транзакцией, предотвращая повторное всплывание старых данных.
Почему в React Hook Form при реализации многошагового визарда с размонтированием шагов необходимо явно задавать опцию shouldUnregister: false?
Анкета подачи заявки на лизинг оборудования из 6 шагов: общие реквизиты, финансовая отчетность за 3 года, сканы уставных документов, бенефициары и выбор банка. Асинхронное сохранение в IndexedDB гарантирует сохранение прикрепленных многостраничных PDF-файлов даже при разрыве соединения.
Выбор провайдера (AWS, GCP, Baremetal) определяет структуру последующих шагов: для AWS запрашиваются IAM Roles и VPC ID, для Baremetal — сетевые CIDR и SSH-ключи. Динамический FSM выстраивает маршрут в зависимости от конфигурации.
Анкеты пациентов с динамическим ветвлением вопросов: ответы на вопросы о симптомах активируют дополнительные блоки клинических тестов. При перезагрузке устройства черновик восстанавливается в той же точке заполнения.
Расчет стоимости полисов с переключением между вкладками «Транспорт», «Недвижимость» и «Здоровье». Пользователь может свободно перемещаться назад для корректировки параметров без потери рассчитанных премий.
Создадим типобезопасный модуль доступа к IndexedDB на чистом API без тяжелых зависимостей:
// lib/draft-storage.ts
const DB_NAME = 'wpskills_forms_db';
const DB_VERSION = 1;
const STORE_NAME = 'wizard_drafts';
export interface FormDraftRecord<T> {
id: string; // Идентификатор формы, например 'enterprise_onboarding'
currentStep: string;
data: T;
schemaVersion: number;
updatedAt: number;
}
// Открытие базы данных с созданием объектного хранилища
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Сохранение черновика в IndexedDB (Асинхронная неблокирующая транзакция)
export async function saveFormDraft<T>(record: FormDraftRecord<T>): Promise<void> {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Чтение черновика с проверкой версии схемы
export async function loadFormDraft<T>(
formId: string,
expectedVersion: number
): Promise<FormDraftRecord<T> | null> {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(formId);
request.onsuccess = () => {
const record = request.result as FormDraftRecord<T> | undefined;
if (!record) {
resolve(null);
return;
}
// Защита от миграции схемы: если сохраненная версия устарела, отбрасываем черновик
if (record.schemaVersion !== expectedVersion) {
console.warn(`Черновик ${formId} устарел (v${record.schemaVersion} !== v${expectedVersion}), сброс.`);
resolve(null);
return;
}
resolve(record);
};
request.onerror = () => reject(request.error);
});
}
// Удаление черновика при успешном завершении визарда
export async function clearFormDraft(formId: string): Promise<void> {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.delete(formId);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}Опишем общую схему данных и дочерние схемы для каждого шага:
import { z } from 'zod';
// Шаг 1: Тип аккаунта
export const AccountTypeSchema = z.object({
accountType: z.enum(['INDIVIDUAL', 'CORPORATE']),
});
// Шаг 2A: Данные физического лица
export const IndividualDetailsSchema = z.object({
fullName: z.string().min(3, 'Укажите ФИО полностью'),
taxNumber: z.string().regex(/^\d{12}$/, 'ИНН физлица состоит из 12 цифр'),
});
// Шаг 2B: Данные юридического лица
export const CorporateDetailsSchema = z.object({
companyName: z.string().min(2, 'Наименование компании обязательно'),
registrationNumber: z.string().regex(/^\d{10}$/, 'ИНН юрлица состоит из 10 цифр'),
beneficiaryCount: z.number().int().min(1, 'Минимум 1 бенефициар'),
});
// Шаг 3: Настройки биллинга
export const PaymentDetailsSchema = z.object({
currency: z.enum(['USD', 'EUR', 'RUB']),
paymentTermsDays: z.number().int().min(0).max(90),
});
// Глобальная схема формы
export const WizardFullSchema = AccountTypeSchema
.and(IndividualDetailsSchema.partial())
.and(CorporateDetailsSchema.partial())
.and(PaymentDetailsSchema);
export type WizardFormData = z.infer<typeof WizardFullSchema>;Создадим кастомный хук конечного автомата для управления переходами:
import { useState, useCallback } from 'react';
import type { WizardFormData } from './schemas';
export type WizardStep =
| 'ACCOUNT_TYPE'
| 'INDIVIDUAL_DETAILS'
| 'CORPORATE_DETAILS'
| 'PAYMENT_SETUP'
| 'REVIEW';
export function useWizardStateMachine(initialStep: WizardStep = 'ACCOUNT_TYPE') {
const [currentStep, setCurrentStep] = useState<WizardStep>(initialStep);
const [history, setHistory] = useState<WizardStep[]>([initialStep]);
// Чистая функция вычисления следующего шага на основе контекста формы
const getNextStep = useCallback((fromStep: WizardStep, data: Partial<WizardFormData>): WizardStep => {
switch (fromStep) {
case 'ACCOUNT_TYPE':
return data.accountType === 'CORPORATE' ? 'CORPORATE_DETAILS' : 'INDIVIDUAL_DETAILS';
case 'INDIVIDUAL_DETAILS':
case 'CORPORATE_DETAILS':
return 'PAYMENT_SETUP';
case 'PAYMENT_SETUP':
return 'REVIEW';
case 'REVIEW':
return 'REVIEW';
}
}, []);
// Переход вперед
const transitionToNext = useCallback((formData: Partial<WizardFormData>) => {
const next = getNextStep(currentStep, formData);
if (next !== currentStep) {
setHistory((prev) => [...prev, next]);
setCurrentStep(next);
}
}, [currentStep, getNextStep]);
// Переход назад по стеку истории
const transitionToPrev = useCallback(() => {
if (history.length <= 1) return;
const newHistory = [...history];
newHistory.pop(); // удаляем текущий
const previous = newHistory[newHistory.length - 1];
setHistory(newHistory);
setCurrentStep(previous);
}, [history]);
return {
currentStep,
setCurrentStep,
history,
canGoBack: history.length > 1,
transitionToNext,
transitionToPrev,
};
}Объединим форму, FSM и IndexedDB в единый компонент:
'use client';
import React, { useEffect, useState, useTransition } from 'react';
import { useForm, type SubmitHandler } from 'react-hook-form';
import {
type WizardFormData,
type WizardStep,
useWizardStateMachine
} from './wizard-fsm';
import {
saveFormDraft,
loadFormDraft,
clearFormDraft
} from '@/lib/draft-storage';
const FORM_ID = 'enterprise_onboarding_v1';
const SCHEMA_VERSION = 1;
export function MultiStepEnterpriseWizard() {
const [isRestoring, setIsRestoring] = useState(true);
const [isSubmitting, startSubmission] = useTransition();
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const {
currentStep,
setCurrentStep,
canGoBack,
transitionToNext,
transitionToPrev,
} = useWizardStateMachine('ACCOUNT_TYPE');
// КРИТИЧЕСКИ ВАЖНО: shouldUnregister: false для сохранения значений при смене шагов
const {
register,
handleSubmit,
trigger,
getValues,
reset,
watch,
formState: { errors },
} = useForm<WizardFormData>({
shouldUnregister: false,
mode: 'onBlur',
defaultValues: {
accountType: 'CORPORATE',
currency: 'USD',
paymentTermsDays: 30,
},
});
// 1. Инициализация: загрузка черновика из IndexedDB при монтировании
useEffect(() => {
async function restoreDraft() {
try {
const draft = await loadFormDraft<WizardFormData>(FORM_ID, SCHEMA_VERSION);
if (draft) {
reset(draft.data);
if (draft.currentStep) {
setCurrentStep(draft.currentStep as WizardStep);
}
}
} catch (err: unknown) {
console.error('Ошибка загрузки черновика:', err);
} finally {
setIsRestoring(false);
}
}
restoreDraft();
}, [reset, setCurrentStep]);
// 2. Дебаунсированное автосохранение черновика в IndexedDB при любых изменениях
useEffect(() => {
if (isRestoring) return;
const subscription = watch((formData) => {
setSaveStatus('saving');
const timer = setTimeout(async () => {
try {
await saveFormDraft({
id: FORM_ID,
currentStep,
data: formData as WizardFormData,
schemaVersion: SCHEMA_VERSION,
updatedAt: Date.now(),
});
setSaveStatus('saved');
} catch (err: unknown) {
console.error('Ошибка фонового сохранения в IndexedDB:', err);
}
}, 500); // Дебаунс 500 мс
return () => clearTimeout(timer);
});
return () => subscription.unsubscribe();
}, [watch, currentStep, isRestoring]);
// 3. Сегментная валидация перед переходом вперед
const handleStepForward = async () => {
let isValid = false;
if (currentStep === 'ACCOUNT_TYPE') {
isValid = await trigger(['accountType']);
} else if (currentStep === 'INDIVIDUAL_DETAILS') {
isValid = await trigger(['fullName', 'taxNumber']);
} else if (currentStep === 'CORPORATE_DETAILS') {
isValid = await trigger(['companyName', 'registrationNumber', 'beneficiaryCount']);
} else if (currentStep === 'PAYMENT_SETUP') {
isValid = await trigger(['currency', 'paymentTermsDays']);
}
if (isValid) {
transitionToNext(getValues());
}
};
// 4. Финальная отправка формы и очистка черновика
const onFinalSubmit: SubmitHandler<WizardFormData> = (fullData) => {
startSubmission(async () => {
try {
// Имитация сетевой мутации
await new Promise((res) => setTimeout(res, 1200));
console.log('Успешная финализация визарда:', fullData);
// Безопасная очистка черновика из IndexedDB
await clearFormDraft(FORM_ID);
alert('Заявка успешно зарегистрирована!');
} catch (err: unknown) {
console.error('Ошибка отправки формы:', err);
}
});
};
if (isRestoring) {
return (
<div className="p-8 text-center text-slate-400">
Восстановление сохраненного сеанса из IndexedDB...
</div>
);
}
return (
<div className="max-w-xl mx-auto p-6 bg-slate-900 border border-slate-800 rounded-2xl shadow-2xl">
{/* Индикатор прогресса и статус автосохранения */}
<div className="flex items-center justify-between pb-4 mb-6 border-b border-slate-800">
<div>
<span className="text-xs uppercase font-bold text-cyan-400 tracking-wider">
Шаг: {currentStep}
</span>
<p className="text-xs text-slate-400">Многошаговый мастер онбординга</p>
</div>
<div className="text-[11px] font-mono text-slate-400">
{saveStatus === 'saving' && <span className="text-amber-400 animate-pulse">● Сохранение черновика...</span>}
{saveStatus === 'saved' && <span className="text-emerald-400">✓ Черновик сохранен</span>}
</div>
</div>
<form onSubmit={handleSubmit(onFinalSubmit)}>
{/* ШАГ 1: ВЫБОР ТИПА АККАУНТА */}
{currentStep === 'ACCOUNT_TYPE' && (
<div className="space-y-4">
<h3 className="text-base font-semibold text-white">Выберите юридический статус:</h3>
<div className="grid grid-cols-2 gap-4">
<label className="p-4 border border-slate-800 rounded-xl cursor-pointer hover:border-cyan-500 flex flex-col gap-2">
<input type="radio" value="CORPORATE" {...register('accountType')} />
<span className="font-semibold text-white text-sm">Юридическое лицо</span>
<span className="text-xs text-slate-400">Для ООО, АО и международных корпораций</span>
</label>
<label className="p-4 border border-slate-800 rounded-xl cursor-pointer hover:border-cyan-500 flex flex-col gap-2">
<input type="radio" value="INDIVIDUAL" {...register('accountType')} />
<span className="font-semibold text-white text-sm">Физическое лицо</span>
<span className="text-xs text-slate-400">Для самозанятых специалистов и ИП</span>
</label>
</div>
</div>
)}
{/* ШАГ 2A: ФИЗИЧЕСКОЕ ЛИЦО */}
{currentStep === 'INDIVIDUAL_DETAILS' && (
<div className="space-y-4">
<h3 className="text-base font-semibold text-white">Персональные данные:</h3>
<div>
<label className="block text-xs text-slate-300 mb-1">ФИО полностью</label>
<input
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-sm text-white"
placeholder="Иванов Иван Иванович"
{...register('fullName')}
/>
{errors.fullName && <p className="text-xs text-red-400 mt-1">{errors.fullName.message}</p>}
</div>
<div>
<label className="block text-xs text-slate-300 mb-1">ИНН физлица (12 цифр)</label>
<input
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-sm text-white"
placeholder="770123456789"
{...register('taxNumber')}
/>
{errors.taxNumber && <p className="text-xs text-red-400 mt-1">{errors.taxNumber.message}</p>}
</div>
</div>
)}
{/* ШАГ 2B: ЮРИДИЧЕСКОЕ ЛИЦО */}
{currentStep === 'CORPORATE_DETAILS' && (
<div className="space-y-4">
<h3 className="text-base font-semibold text-white">Реквизиты организации:</h3>
<div>
<label className="block text-xs text-slate-300 mb-1">Наименование компании</label>
<input
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-sm text-white"
placeholder="ООО Вектор Технологии"
{...register('companyName')}
/>
{errors.companyName && <p className="text-xs text-red-400 mt-1">{errors.companyName.message}</p>}
</div>
<div>
<label className="block text-xs text-slate-300 mb-1">ИНН юрлица (10 цифр)</label>
<input
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-sm text-white"
placeholder="7701234567"
{...register('registrationNumber')}
/>
{errors.registrationNumber && <p className="text-xs text-red-400 mt-1">{errors.registrationNumber.message}</p>}
</div>
<div>
<label className="block text-xs text-slate-300 mb-1">Число бенефициаров</label>
<input
type="number"
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-sm text-white"
{...register('beneficiaryCount', { valueAsNumber: true })}
/>
</div>
</div>
)}
{/* ШАГ 3: БИЛЛИНГ И ПЛАТЕЖИ */}
{currentStep === 'PAYMENT_SETUP' && (
<div className="space-y-4">
<h3 className="text-base font-semibold text-white">Параметры взаиморасчетов:</h3>
<div>
<label className="block text-xs text-slate-300 mb-1">Валюта договора</label>
<select
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-sm text-white"
{...register('currency')}
>
<option value="USD">USD ($)</option>
<option value="EUR">EUR (€)</option>
<option value="RUB">RUB (₽)</option>
</select>
</div>
<div>
<label className="block text-xs text-slate-300 mb-1">Отсрочка платежа (в днях)</label>
<input
type="number"
className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-sm text-white"
{...register('paymentTermsDays', { valueAsNumber: true })}
/>
</div>
</div>
)}
{/* ШАГ 4: ОБЗОР И ПОДТВЕРЖДЕНИЕ */}
{currentStep === 'REVIEW' && (
<div className="space-y-3 p-4 bg-slate-950 border border-slate-800 rounded-xl">
<h3 className="text-base font-semibold text-cyan-400">Проверьте введенные параметры:</h3>
<div className="text-xs space-y-1.5 text-slate-300 font-mono">
<p>Статус: {getValues('accountType')}</p>
{getValues('accountType') === 'CORPORATE' ? (
<>
<p>Компания: {getValues('companyName')}</p>
<p>ИНН: {getValues('registrationNumber')}</p>
<p>Бенефициаров: {getValues('beneficiaryCount')}</p>
</>
) : (
<>
<p>ФИО: {getValues('fullName')}</p>
<p>ИНН: {getValues('taxNumber')}</p>
</>
)}
<p>Валюта: {getValues('currency')}</p>
<p>Отсрочка: {getValues('paymentTermsDays')} дней</p>
</div>
</div>
)}
{/* Кнопки навигации */}
<div className="flex justify-between items-center mt-6 pt-4 border-t border-slate-800">
<button
type="button"
onClick={transitionToPrev}
disabled={!canGoBack || isSubmitting}
className="px-4 py-2 text-sm text-slate-300 bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed rounded-lg transition-colors"
>
← Назад
</button>
{currentStep !== 'REVIEW' ? (
<button
type="button"
onClick={handleStepForward}
className="px-5 py-2 text-sm font-semibold text-white bg-cyan-600 hover:bg-cyan-500 rounded-lg transition-colors"
>
Далее →
</button>
) : (
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-2 text-sm font-semibold text-white bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-800 rounded-lg transition-colors flex items-center gap-2"
>
{isSubmitting && <div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />}
Подтвердить и отправить
</button>
)}
</div>
</form>
</div>
);
}При монтировании визард отправляет асинхронный запрос в IndexedDB. Если запись найдена и schemaVersion совпадает, вызывается reset(draft.data).
Если версия схемы в IndexedDB устарела, запись сбрасывается для предотвращения ошибок несоответствия полей.
Пользователь модифицирует поля формы. RHF watch stream эмитит поток мутаций данных, поступающий в дебаунсер.
Дебаунс 500 мс отсекает высокочастотные события клавиатурного ввода, группируя изменения.
Таймер запускает асинхронную транзакцию readwrite. Структурированный клон объекта сохраняется в IndexedDB без подвисаний UI.
Основной поток браузера сохраняет стабильные 60 FPS при взаимодействии пользователя с интерфейсом.
При клике 'Далее' метод trigger(segmentFields) проверяет валидность полей текущего шага. FSM рассчитывает следующий шаг ветвления.
shouldUnregister: false гарантирует сохранение данных предыдущих шагов в оперативной памяти формы.
После подтверждения успешного ответа от Server Action вызывается clearFormDraft(), физически удаляя запись черновика.
Предотвращает повторное восстановление черновика при следующем входе пользователя в приложение.
Команда обновила приложение в продакшене, удалив поле 'faxNumber' и сделав обязательным поле 'countryCode'. Что произойдет, если пользователь вернется на сайт с черновиком недельной давности без механизма версионирования schemaVersion?
Если не указать { shouldUnregister: false } в useForm:
// ❌ ПОТЕРЯ ДАННЫХ: При смене шага все поля предыдущего шага удаляются из памяти!
const form = useForm();При переходе со Шага 1 на Шаг 2 компонент Шага 1 размонтируется. RHF автоматически сотрет все зарегистрированные в нем значения. При возврате назад пользователь увидит пустые поля, а финальный handleSubmit отправит только поля последнего активного шага.
Использование синхронного localStorage.setItem('draft', JSON.stringify(data)) на форме с большим количеством вложенных полей вызывает дропы кадров при печати (INP свыше 180 мс). Если форма поддерживает прикрепление файлов (аватары, сканы договоров), запись гарантированно завершится аварийным исключением QuotaExceededError. Черновики должны сохраняться строго асинхронно через IndexedDB.
Переход на следующий шаг простым вызовом setCurrentStep(step + 1) без предварительного вызова await trigger(['field1', 'field2']). Пользователь может дойти до финального экрана с пустыми обязательными полями. При нажатии кнопки «Отправить» форма выдаст ошибки на невидимых экранах, и пользователь не поймет, почему сабмит заблокирован.
Если после успешной отправки формы на сервер забыть вызвать clearFormDraft(), то при следующей попытке пользователя создать новую сущность (например, зарегистрировать вторую компанию) визард радостно восстановит реквизиты старой уже отправленной компании, что приведет к дублированию или перезаписи данных.