Коротко: Разбираем архитектуру многошаговых форм в React и TypeScript: пошаговая валидация с Zod, персистентность данных, React Hook Form, Zustand и управление стейтом.
Разработка многошаговых экранных форм (визардов) — типовая задача при создании личных кабинетов, checkout-процессов, онбордингов и сложных B2B-интерфейсов. На первый взгляд реализация кажется простой: переключай экраны по числовому индексу и собирай значения полей. Однако при росте кодовой базы и усложнении сценариев возникают инженерные проблемы: потеря введенных данных при размонтировании шагов, сломанная навигация по кнопкам браузера «Назад»/«Вперед», гонки состояний при асинхронной валидации и рассинхронизация зависимых полей.
Разберем, как спроектировать масштабируемую архитектуру Multi-Step Wizard на стеке React и TypeScript, изолировать состояние отдельных шагов и гарантировать сохранность пользовательского контекста.
В чем сложность управления состоянием в Multi-Step Wizard?
В стандартных линейных формах все инпуты присутствуют в DOM одновременно, а валидация выполняется перед отправкой. В многошаговых интерфейсах логика распределена во времени и пространстве пользовательского пути, что создает две ключевые проблемы.
Размонтирование компонентов (Unmounting) и потеря локального стейта
Для оптимизации рендера и изоляции верстки шаги визарда обычно рендерятся условно:
{currentStep === 1 && <StepPersonalDetails />}
{currentStep === 2 && <StepCompanyInfo />}
{currentStep === 3 && <StepPayment />}
Если компонент StepPersonalDetails хранит значения своих полей во внутреннем useState, при переходе на шаг 2 он размонтируется. При возврате назад компонент монтируется заново со значениями по умолчанию, а весь пользовательский ввод теряется.
Хранение состояния формы нельзя привязывать к жизненному циклу компонентов представления. Необходим внешний слой данных (Persistence Layer), который переживает монтирование и демонтирование UI-экранов.
Нелинейная навигация и зависимые поля (Conditional Steps)
Бизнес-логика часто требует изменения маршрута в зависимости от промежуточных ответов:
- Если пользователь выбрал тип аккаунта «Физическое лицо», шаг «Реквизиты компании» исключается из цепочки.
- Если на шаге 1 пользователь изменил страну доставки, то ранее выбранные на шаге 2 способы получения и пункты выдачи становятся невалидными.
В таких сценариях состояние должно не просто сохраняться, но и инвалидироваться при изменении влияющих на него родительских параметров (stale state management).
Уровни персистентности данных формы: от памяти до сервера
Выбор места хранения контекста определяется критичностью данных и сценарием пользовательской сессии.
+-------------------------------------------------------------------+
| 1. In-Memory: React Context / Zustand / Redux |
| - Быстрый доступ, изоляция в рамках жизненного цикла вкладки |
+-------------------------------------------------------------------+
│
▼
+-------------------------------------------------------------------+
| 2. Browser Storage: sessionStorage / localStorage |
| - Защита от случайного F5, восстановление черновика |
+-------------------------------------------------------------------+
│
▼
+-------------------------------------------------------------------+
| 3. URL State: Search Params (?step=2&plan=pro) |
| - Синхронизация с историей браузера, диплинки |
+-------------------------------------------------------------------+
│
▼
+-------------------------------------------------------------------+
| 4. Server-side Drafts: REST / GraphQL / RPC |
| - Кросс-девайс сценарии, многодневные заявки |
+-------------------------------------------------------------------+
Уровень 1: In-Memory (React State / Context API / Zustand)
Хранение в оперативной памяти приложения. Данные доступны мгновенно, синхронно и не требуют сериализации в JSON. Главное ограничение — полный сброс состояния при перезагрузке страницы (F5) или случайном закрытии вкладки.
Уровень 2: Browser Storage (sessionStorage vs localStorage)
sessionStorageоптимален для большинства сессионных визардов (например, оформление заказа). Данные изолированы внутри конкретной вкладки браузера, переживают обновление страницы и автоматически очищаются при закрытии вкладки. Это защищает от конфликтов, если пользователь открыл два разных заказа параллельно.localStorageприменяют для длительных анкет, заполнение которых может занять несколько дней. Требует обязательной логики очистки после успешного сабмита или по истечении срока жизни (TTL).
Уровень 3: URL Search Params для фиксации шага и фильтров
Хранение идентификатора шага в URL (/checkout?step=billing) решает три задачи:
- Корректно работают нативные кнопки браузера «Назад» и «Вперед» (
history.pushState). - Появляется возможность делиться ссылкой на конкретный шаг (если к нему есть доступ).
- При обновлении страницы пользователь остается на том же экране.
Уровень 4: Серверные черновики (Server-side Drafts)
Для финансовых, страховых или объемных юридических анкет клиентской персистентности недостаточно. Переход на каждый следующий шаг инициирует фоновый запрос (auto-save draft) на сервер с фиксацией draftId.
Архитектурные паттерны реализации контекста шагов
Подход 1: Единый контейнер + React Hook Form (FormProvider)
Наиболее распространенный и производительный подход для одностраничных визардов. Корневой контейнер инициализирует инстанс формы через useForm, а дочерние экраны получают доступ к контексту и методам регистрации инпутов через FormProvider и хук useFormContext.
import React from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const wizardSchema = z.object({
fullName: z.string().min(2, 'Укажите имя'),
email: z.string().email('Некорректный email'),
companyName: z.string().optional(),
});
type WizardFormData = z.infer<typeof wizardSchema>;
export const WizardContainer = () => {
const [step, setStep] = React.useState(1);
const methods = useForm<WizardFormData>({
resolver: zodResolver(wizardSchema),
mode: 'onBlur',
defaultValues: {
fullName: '',
email: '',
companyName: '',
},
});
const nextStep = async (fieldsToValidate: Array<keyof WizardFormData>) => {
const isStepValid = await methods.trigger(fieldsToValidate);
if (isStepValid) setStep((prev) => prev + 1);
};
const onSubmit = (data: WizardFormData) => {
console.log('Submitted Payload:', data);
};
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
{step === 1 && <StepProfile onNext={() => nextStep(['fullName', 'email'])} />}
{step === 2 && <StepCompany onPrev={() => setStep(1)} />}
</form>
</FormProvider>
);
};
Подход 2: Глобальный легковесный стейт-менеджер (Zustand)
Если шаги формы разнесены по разным страницам фреймворка (например, вложенные роуты в Next.js App Router: /wizard/step-1, /wizard/step-2), React Context может сбрасываться при переходе между лейаутами. В таких случаях оправдано использование стора на базе Zustand с middleware persist.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface WizardStore {
formData: {
fullName: string;
email: string;
companyName?: string;
};
currentStep: number;
setField: (field: string, value: unknown) => void;
setStep: (step: number) => void;
resetWizard: () => void;
}
export const useWizardStore = create<WizardStore>()(
persist(
(set) => ({
formData: { fullName: '', email: '', companyName: '' },
currentStep: 1,
setField: (field, value) =>
set((state) => ({
formData: { ...state.formData, [field]: value },
})),
setStep: (step) => set({ currentStep: step }),
resetWizard: () =>
set({
formData: { fullName: '', email: '', companyName: '' },
currentStep: 1,
}),
}),
{
name: 'wizard-session-cache',
storage: createJSONStorage(() => sessionStorage),
}
)
);
Подход 3: Машина состояний (FSM) для сложных сценариев ветвления
Когда граф переходов ветвится на множество альтернативных путей (например: Шаг 1 → Шаг 2А или Шаг 2Б в зависимости от скоринга), управление через числовой step += 1 приводит к громоздким конструкциям из if/else.
В таких случаях применяется конечный автомат (FSM), например на базе библиотеки XState. Состояние делится на:
state(в каком узле графа находится пользователь);context(накопленные данные формы).
Переходы выполняются строго по декларативным событиям (NEXT, BACK, SKIP), что исключает открытие недопустимого экрана в обход бизнес-правил.
Валидация и жизненный цикл шага
Распространенная ошибка проектирования многошаговой валидации — запуск единой глобальной схемы на каждом промежуточном шаге. Если проверять всю форму на Шаге 1, валидатор выдаст ошибки по еще не заполненным полям Шагов 2 и 3.
Пошаговая (Per-Step) валидация через Zod
Эффективный паттерн — композиция модульных схем:
import { z } from 'zod';
export const stepOneSchema = z.object({
firstName: z.string().min(1, 'Имя обязательно'),
lastName: z.string().min(1, 'Фамилия обязательна'),
});
export const stepTwoSchema = z.object({
deliveryAddress: z.string().min(5, 'Укажите точный адрес'),
deliveryType: z.enum(['courier', 'pickup']),
});
// Общая схема объединяет модули
export const fullFormSchema = stepOneSchema.merge(stepTwoSchema);
export type StepOneData = z.infer<typeof stepOneSchema>;
export type StepTwoData = z.infer<typeof stepTwoSchema>;
export type FullFormData = z.infer<typeof fullFormSchema>;
При переходе между экранами валидируется только срез полей текущего шага:
const isCurrentStepValid = await trigger(['firstName', 'lastName']);
Статусы шагов: locked, current, completed, stale
Для построения надежного интерфейса навигации каждому шагу присваивается один из статусов на основе состояния данных, а не только истории переходов:
| Статус | Описание | Доступность для клика |
|---|---|---|
| Current | Текущий активный экран | Активен |
| Completed | Шаг успешно заполнен и валидирован | Доступен для возврата |
| Locked | Будущий шаг, доступ закрыт до прохождения предыдущих | Заблокирован (Disabled) |
| Stale | Данные шага устарели из-за правок на более ранних экранах | Требует повторной валидации |
type StepStatus = 'locked' | 'current' | 'completed' | 'stale';
interface StepDescriptor {
id: string;
title: string;
status: StepStatus;
}
Шаг проверки (Review Step) и безопасный возврат к редактированию
Финальный шаг визарда представляет собой сводку всех заполненных данных со ссылками «Изменить» (Change/Edit).
Если пользователь возвращается с экрана Review на Шаг 1 и меняет ключевой параметр, архитектура формы должна пометить последующие зависимые шаги как stale или сбросить значения их полей, чтобы предотвратить отправку некорректных данных.
Практический пример реализации на React + TypeScript
Компонент отдельного шага взаимодействует с общим контекстом:
import React from 'react';
import { useFormContext } from 'react-hook-form';
export const StepProfile: React.FC<{ onNext: () => void }> = ({ onNext }) => {
const { register, formState: { errors } } = useFormContext();
return (
<div className="space-y-4">
<h2 className="text-xl font-bold">Личные данные</h2>
<div>
<label className="block text-sm">Имя</label>
<input
{...register('firstName')}
className="border p-2 rounded w-full"
/>
{errors.firstName && (
<p className="text-red-500 text-xs">{String(errors.firstName.message)}</p>
)}
</div>
<button
type="button"
onClick={onNext}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Далее
</button>
</div>
);
};
Синхронизация черновика с sessionStorage через кастомный хук:
import { useEffect } from 'react';
import { UseFormReturn, FieldValues } from 'react-hook-form';
export function useFormDraftSync<T extends FieldValues>(
formMethods: UseFormReturn<T>,
storageKey: string
) {
const { watch, reset } = formMethods;
// Восстановление данных при инициализации
useEffect(() => {
const savedDraft = sessionStorage.getItem(storageKey);
if (savedDraft) {
try {
const parsed = JSON.parse(savedDraft);
reset(parsed);
} catch (e) {
console.error('Ошибка восстановления черновика формы', e);
}
}
}, [reset, storageKey]);
// Подписка на изменения формы
useEffect(() => {
const subscription = watch((values) => {
sessionStorage.setItem(storageKey, JSON.stringify(values));
});
return () => subscription.unsubscribe();
}, [watch, storageKey]);
}
Типичные антипаттерны и ошибки при разработке визардов
- Сброс формы через
reset()без аргументов при возврате назад. Вызов стандартных методов очистки стирает уже введенные валидные значения с других экранов. - Рендер всех шагов в DOM одновременно со скрытием через
display: none. Такой прием часто используют, чтобы избежать потери стейта, но он нарушает доступность (a11y), сбивает фокус по табуляции и заставляет браузер держать в памяти невидимые тяжелые компоненты (карты, таблицы, редакторы). - Хранение объектов
FileвsessionStorage. Хранилища браузера принимают только сериализуемые строки. ОбъектFileилиBlobнельзя сохранить черезJSON.stringify(). Для файлов необходимо либо хранить их в памяти корневого контейнера, либо загружать на сервер сразу при выборе и сохранять в контексте только строковый ID. - Отсутствие инвалидации зависимых шагов. Если на шаге выбора валюты пользователь переключился с USD на EUR, а на шаге оплаты остались реквизиты для долларового перевода, форма отправит взаимоисключающий пейлоад.
- Игнорирование истории браузера. Если переключение экранов не фиксируется в URL через
pushStateилиreplaceState, клик по системной кнопке «Назад» уводит пользователя со страницы визарда, сбрасывая прогресс.
Часто задаваемые вопросы (FAQ)
Где лучше хранить номер активного шага: в URL или во внутреннем стейте?
Оптимально использовать URL (Search Params или динамические сегменты пути). Это сохраняет стандартное поведение кнопок браузера «Вперед / Назад», позволяет восстановить нужный экран при обновлении страницы (F5) и дает возможность формировать ссылки на конкретный шаг.
Как обрабатывать валидацию, если поля на шаге 3 зависят от выбора на шаге 1?
Используйте условные схемы в Zod с помощью .discriminatedUnion() или .superRefine(). В момент валидации шага 3 передавайте в валидатор не только локальные поля, но и родительский флаг из общего контекста формы.
Что выбрать для хранения промежуточных данных: sessionStorage или localStorage?
Для большинства многошаговых процессов (чекаут, регистрация, заявка) предпочтительнее sessionStorage. Данные изолированы внутри одной вкладки, что исключает пересечения, если пользователь открыл два разных сценария в разных окнах. localStorage используют для длинных черновиков, заполняемых в течение нескольких дней.
Почему React Context может вызывать просадки производительности в больших формах?
При обновлении любого поля в React Context инициируется ререндер всех компонентов-подписчиков. Если форма содержит десятки полей, ввод символа в одном инпуте вызывает лишние перерисовки. Для оптимизации используют неконтролируемые поля через react-hook-form (где обновления изолированы через рефы) либо специализированные атомарные сторы (Zustand, Jotai).
Как корректно обрабатывать загрузку файлов (File / Blob) в многошаговых формах?
Файлы нельзя сериализовать в Web Storage. Надежный паттерн: загружать файл на сервер сразу после выбора (через фоновый асинхронный upload), получать идентификатор файла (id или url) и сохранять в стейт формы только полученную строку. Если загрузка допустима только в конце, сохраняйте ссылки на инстансы File в In-Memory стейте корневого компонента формы.
Заключение
Масштабируемая архитектура Multi-Step Wizard опирается на три ключевых принципа:
- Изоляция ответственности: экраны отвечают только за верстку и локальные инпуты, а общее состояние формы и логика переходов вынесены в корневой контейнер или стор.
- Селективная валидация: каждый шаг проверяет только свой срез схемы данных, не блокируя навигацию ошибками будущих экранов.
- Отказоустойчивость: синхронизация с
sessionStorageи отражение шага в URL гарантируют, что случайная перезагрузка страницы или навигация по истории браузера не приведут к потере заполненных данных.




.svg.webp)




