Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Архитектура Multi-Step Wizard в React: надежное сохранение контекста и состояния между шагами

Тимофей Ищенко

Коротко: Разбираем архитектуру многошаговых форм в 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) решает три задачи:

  1. Корректно работают нативные кнопки браузера «Назад» и «Вперед» (history.pushState).
  2. Появляется возможность делиться ссылкой на конкретный шаг (если к нему есть доступ).
  3. При обновлении страницы пользователь остается на том же экране.

Уровень 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. Состояние делится на:

  1. state (в каком узле графа находится пользователь);
  2. 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]);
}

Типичные антипаттерны и ошибки при разработке визардов

  1. Сброс формы через reset() без аргументов при возврате назад. Вызов стандартных методов очистки стирает уже введенные валидные значения с других экранов.
  2. Рендер всех шагов в DOM одновременно со скрытием через display: none. Такой прием часто используют, чтобы избежать потери стейта, но он нарушает доступность (a11y), сбивает фокус по табуляции и заставляет браузер держать в памяти невидимые тяжелые компоненты (карты, таблицы, редакторы).
  3. Хранение объектов File в sessionStorage. Хранилища браузера принимают только сериализуемые строки. Объект File или Blob нельзя сохранить через JSON.stringify(). Для файлов необходимо либо хранить их в памяти корневого контейнера, либо загружать на сервер сразу при выборе и сохранять в контексте только строковый ID.
  4. Отсутствие инвалидации зависимых шагов. Если на шаге выбора валюты пользователь переключился с USD на EUR, а на шаге оплаты остались реквизиты для долларового перевода, форма отправит взаимоисключающий пейлоад.
  5. Игнорирование истории браузера. Если переключение экранов не фиксируется в 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 опирается на три ключевых принципа:

  1. Изоляция ответственности: экраны отвечают только за верстку и локальные инпуты, а общее состояние формы и логика переходов вынесены в корневой контейнер или стор.
  2. Селективная валидация: каждый шаг проверяет только свой срез схемы данных, не блокируя навигацию ошибками будущих экранов.
  3. Отказоустойчивость: синхронизация с sessionStorage и отражение шага в URL гарантируют, что случайная перезагрузка страницы или навигация по истории браузера не приведут к потере заполненных данных.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также