Вступление

Модальные окна — один из самых недооценённых слоёв UI-архитектуры. Формы, подтверждения, панели действий — в любом крупном проекте их десятки. И почти в каждом проекте их управление со временем превращается в хаос.

Не потому что разработчики ленивые. А потому что модалки обманчиво просты. useState(false) — и готово. Пока модалка одна, в одном месте, с одним набором данных — проблем нет.

Проблемы начинаются, когда проект растёт:

  • Одна модалка нужна в десяти местах интерфейса — и в каждом месте свой <Modal /> с одними и теми же пропсами

  • Модалка открывается из компонента, вложенного на пять уровней ниже того, где она рендерится

  • При открытии одной модалки нужно скрыть другую, а при закрытии — вернуть обратно

  • Одна и та же модалка в разных контекстах использует разные API, разные данные и разную логику после закрытия

Каждую из этих проблем можно решить ad hoc — эффектом, пропом, контекстом. Но точечные решения накапливаются, и через полгода вы обнаруживаете: половина компонентов знает о модалках, которые им не принадлежат, а открытие одного окна — это цепочка из пяти диспатчей в пяти файлах.

Загуглите «управление модальными окнами в React». Найдёте десятки статей с одним и тем же содержанием:

const [isOpen, setIsOpen] = useState(false);

Продвинутые авторы вынесут это в хук:

const { isOpen, open, close } = useModal();

На этом — всё. Дальше вам предлагают «масштабировать по мере необходимости».

Эта статья — о том, как выстроить архитектуру управления модальными окнами, которая масштабируется. Не абстрактные принципы, а конкретная реализация: типизированный стек, стратегии открытия и паттерн для модалок с разными контекстами вызова.

Как модалки становятся проблемой

Чтобы примеры были конкретными, возьмём реальный сценарий: маркетплейс, админка продавца. На странице товаров — таблица, у каждого товара есть маркировка (IMEI, SGTIN и т.д.), которую нужно вносить через модалку с формой, валидацией, загрузкой файлов и несколькими шагами.

Шаг 1: Модалка в строке таблицы

У каждого товара в таблице — кнопка «Внести маркировку» в выпадающем меню. При клике открывается модалка IdentificationModal — тяжёлая: таблица заказов внутри, инпуты для каждого, валидация, загрузка Excel-шаблона, сохранение.

Самый очевидный подход — модалка рядом с кнопкой:

const ProductRow = ({ product }) => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <tr>
      <td>{product.name}</td>
      <td>{product.barcode}</td>
      <td>
        <Button onClick={() => setIsOpen(true)}>Внести маркировку</Button>
        <IdentificationModal
          isOpen={isOpen}
          onClose={() => setIsOpen(false)}
          productId={product.id}
        />
      </td>
    </tr>
  );
};

Просто, понятно, работает. Пока товаров десять.

А если их тысяча? Тысяча строк — тысяча экземпляров <IdentificationModal /> в DOM. Все скрыты, но все существуют. А IdentificationModal — не лёгкий <div>. Внутри — таблица заказов, инпуты с масками, валидация, хуки для API-запросов, stepper. Умножьте это на тысячу.

Очевидно: модалку нельзя рендерить в каждой строке.

Шаг 2: Подняли модалку — получили prop drilling

Выносим модалку на уровень страницы. Теперь она одна:

const ProductsPage = () => {
  const [identTarget, setIdentTarget] = useState(null);

  return (
    <>
      <ProductsTable onOpenIdent={(productId) => setIdentTarget(productId)} />
      <IdentificationModal
        isOpen={!!identTarget}
        onClose={() => setIdentTarget(null)}
        productId={identTarget}
      />
    </>
  );
};

Модалка одна — хорошо. Но onOpenIdent нужно доставить от страницы до кнопки:

ProductsPage            ← useState + модалка
  └── ProductsTable     ← прокидывает onOpenIdent
       └── TableBody    ← прокидывает onOpenIdent
            └── ProductRow           ← прокидывает onOpenIdent
                 └── ActionsMenu     ← наконец вызывает onOpenIdent(id)

Четыре промежуточных компонента передают проп, который им самим не нужен. Для одной модалки — терпимо. Для пяти — каждый промежуточный компонент становится курьером десятка обработчиков.

Решение напрашивается — глобальный стейт. Вынесли isOpen и data в Redux. Кнопка в строке диспатчит openIdent(productId), модалка на странице читает стейт. Prop drilling решён.

Шаг 3: Появился второй контекст — и всё усложнилось

Продукт-менеджер: «Добавь возможность вносить маркировку для всех товаров сразу — кнопка в шапке таблицы. И для корзин тоже — там свои эндпоинты».

Теперь IdentificationModal открывается из трёх мест:

Место

Какие заказы

API для загрузки

API для скачки Excel

После сохранения

Шапка таблицы

Все заказы по типу маркировки

ordersApi.getIdentOrders()

ordersApi.getExcel()

Строка таблицы

Один заказ (фильтр по orderId)

ordersApi.getIdentOrders() → .filter(id)

ordersApi.getExcel()

Шапка корзины

Заказы корзины

cartsApi.getCartIdentOrders(cartId)

cartsApi.getExcel() 

UI модалки одинаковый: таблица заказов, инпуты, кнопки сохранения/загрузки. Но API — принципиально разные. Не просто разные параметры — разные эндпоинты, разные RTK Query хуки, разная постобработка.

Наивный подход — всё засунуть в модалку:

const IdentificationModal = ({ isOpen, onClose }) => {
  // --- Данные из трёх разных мест стора ---
  const orderId = useSelector(selectIdentOrderId);
  const cartId = useSelector(selectIdentCartId);
  const allOrders = useSelector(selectAllOrders);

  // --- Разные RTK Query хуки ---
  const [getOrdersIdent] = ordersApi.useLazyGetIdentOrdersQuery();
  const [getCartIdent] = cartsApi.useLazyGetCartIdentOrdersQuery();

  // --- Загрузка заказов: угадываем контекст по наличию данных ---
  const fetchOrders = async (modalType) => {
    if (cartId) {
      const { orders } = await getCartIdent().unwrap();
      return orders;
    }

    const { orders } = await getOrdersIdent().unwrap();

    if (orderId) {
      return orders.filter((o) => o.id === orderId);
    }

    return orders;
  };

  // --- Скачивание шаблона: разные эндпоинты ---
  const [downloadOrdersExcel] = ordersApi.useLazyDownloadMetaOrdersExcelQuery();
  const [downloadCartExcel] = cartsApi.useLazyDownloadCartIdentOrdersExcelQuery();

  const downloadTemplate = async (modalType) => {
    if (cartId) {
      const tpl = await downloadCartExcel().unwrap();
      download(tpl);
    } else {
      const tpl = await downloadOrdersExcel().unwrap();
      download(tpl);
    }
  };

  // --- Сохранение: одинаковый API, но для корзины ещё инвалидация ---
  const handleSave = async (orders) => {
    await saveMeta({ chapter, modalType, orders });
    if (cartId) {
      dispatch(cartsApi.util.invalidateTags([{ type: 'Carts', id: cartId }]));
    }
    onClose();
  };

  // --- Загрузка файла: тоже с инвалидацией ---
  const handleUpload = async (file) => {
    await uploadFile({ file, chapter, modalType });
    if (cartId) {
      dispatch(cartsApi.util.invalidateTags([{ type: 'Carts', id: cartId }]));
    }
    onClose();
  };

  // --- Аналитика ---
  const handleAnalytics = (event) => {
    if (orderId) {
      sendAnalytics(event, { orderId, type: 'row' });
    } else if (cartId) {
      sendAnalytics(event, { cartId, type: 'cart' });
    } else {
      sendAnalytics(event, { type: 'header' });
    }
  };

  return (
    <Drawer isOpened={isOpen} onClose={onClose}>
      {/* ... */}
    </Drawer>
  );
};

Сколько мест с if — загрузка заказов, скачивание шаблона, сохранение, загрузка файла, аналитика. И это три источника. Добавили четвёртый — ещё if в каждом из пяти мест.

Модалка знает про ordersApi, про cartsApi, про инвалидацию кеша корзин, про фильтрацию по orderId. Она превратилась в God Object: знает обо всех контекстах, обо всех API, обо всех побочных эффектах.

«Так передай всё через пропсы — зачем модалке знать про все это?»

Логичная мысль. Сделать IdentificationModal чистой UI-компонентой: принимает orders, onSave, onUpload, onDownloadTemplate — и не знает, откуда данные. Пусть каждое место вызова само готовит API-хендлеры и передаёт их.

Отлично, именно так и нужно. Но кто передаёт эти пропсы? Модалка рендерится на уровне страницы — один раз, без дублирования. А вызывается из строки таблицы, из шапки, из корзины. Каждое место — свой API-адаптер, свои хуки, своя постобработка.

Страница не знает, какой из трёх контекстов сейчас активен. Она не может заранее подготовить пропсы — потому что не знает, будет ли это один заказ из строки, все заказы из шапки или заказы корзины с cartId.

Нужен механизм, который:

  • позволяет триггеру передать данные при вызове open()

  • доставляет их модалке на уровне страницы без prop drilling

  • и при этом разделяет логику по контекстам — чтобы модалка осталась чистой

Именно это делает overlay-стек + обёртки. Но сначала — ещё одна проблема.

Шаг 4: Модалки зависят друг от друга

Параллельно с предыдущими проблемами появляется ещё одна.

Пользователь выделил товары чекбоксами — снизу выехала панель массовых действий. Нажал «Внести маркировку» — должна открыться модалка идентификации. Панель должна скрыться. Закрыл модалку — панель должна вернуться.

С отдельными флагами в Redux:

openIdent: (state) => {
  state.isIdentOpen = true;
  state.isBulkPanelOpen = false; // не забыть скрыть панель
},
closeIdent: (state) => {
  state.isIdentOpen = false;
  state.isBulkPanelOpen = true; // а если выделение сняли, пока модалка была открыта?
},

Два модальных окна — уже связаны. А если из модалки идентификации пользователь нажимает «Закрыть» и появляется модалка подтверждения («Вы уверены? Несохранённые данные будут потеряны»)? При подтверждении — закрыть обе, при отмене — вернуть идентификацию:

openConfirmClose: (state) => {
  state.isConfirmOpen = true;
  state.isIdentOpen = false;
},
cancelConfirmClose: (state) => {
  state.isConfirmOpen = false;
  state.isIdentOpen = true;
},
confirmClose: (state) => {
  state.isConfirmOpen = false;
  state.isIdentOpen = false;
  state.isBulkPanelOpen = true;
},

Три модалки — шесть функций, каждая вручную управляет видимостью остальных. Забыли в одном месте вернуть панель — баг. Добавили четвёртую модалку — переписываете все цепочки.

И нет порядка. Пять флагов isOpen: true — какая «поверх» какой? Какую вернуть при закрытии? Плоская структура { isIdentOpen, isConfirmOpen, isBulkPanelOpen } этого не знает. Это конечный автомат, размазанный по редьюсерам.

Итог: три проблемы, которые не решаются по отдельности

  1. Рендеринг и доступ. Модалка должна рендериться один раз, но открываться из любого места дерева без prop drilling.

  2. Разные контексты вызова. Одна модалка — разные данные, шаги, API, постобработка, аналитика, обработка ошибок. Засовывать в модалку — God Object из if-ов. Выносить наружу — как разделить?

  3. Взаимозависимость. Открытие одной модалки должно скрывать другую, закрытие — возвращать. Ручное управление флагами не масштабируется.

Нужна система, которая решает все три сразу. Не набор isOpen-флагов — а стек с правилами, типизированными данными и стратегиями открытия.

Решение: типизированный стек с overlay-хуками

  1. Redux-стек — единое хранилище с порядком и стратегиями открытия

  2. Типизированные overlay-хуки — открытие из любого места без prop drilling

  3. Source-обёртки — разделение логики по контексту вызова

Начнём с фундамента.

Слой 1: Redux-слайс

export enum EOverlayName {
  BulkActions = 'bulkActions',
  IdentFromHeader = 'identFromHeader',
  IdentFromRow = 'identFromRow',
  IdentFromCart = 'identFromCart',
  EditProduct = 'editProduct',
}

export enum EOverlayStrategy {
  Reset = 'reset',     // сбросить стек, открыть одну
  Replace = 'replace', // скрыть текущую, открыть новую, при закрытии — вернуть
  Stack = 'stack',     // открыть поверх, обе видимы
}
export type TOverlayData = {
  [EOverlayName.BulkActions]: undefined;
  [EOverlayName.IdentFromHeader]: { modalType: EIdentModalType };
  [EOverlayName.IdentFromRow]: { modalType: EIdentModalType; orderId: number };
  [EOverlayName.IdentFromCart]: { modalType: EIdentModalType; cartId: string };
  [EOverlayName.EditProduct]: { productId: number };
};
type TOverlayItem = {
  name: EOverlayName;
  isVisible: boolean;
};

type TOverlaysSliceState = {
  stack: Array<TOverlayItem>;
  data: TOverlayData;
};

stack — порядок и видимость. data — типизированные данные каждой модалки, хранятся отдельно от стека.

openOverlay: (state, action) => {
  const { overlayName, strategy } = action.payload;

  switch (strategy) {
    case EOverlayStrategy.Reset:
      state.stack = [{ name: overlayName, isVisible: true }];
      break;

    case EOverlayStrategy.Replace: {
      const topItem = state.stack.at(-1);
      if (topItem) topItem.isVisible = false;  // скрыть, но оставить в стеке
      state.stack.push({ name: overlayName, isVisible: true });
      break;
    }

    case EOverlayStrategy.Stack:
      state.stack.push({ name: overlayName, isVisible: true });
      break;
  }
},

closeOverlay: (state) => {
  state.stack.pop();
  const newTop = state.stack.at(-1);
  if (newTop) newTop.isVisible = true;  // вернуть предыдущую
},

removeOverlay: (state, action) => {
  const { overlayName } = action.payload;
  const index = state.stack.findIndex((item) => item.name === overlayName);
  if (index === -1) return;

  const wasOnTop = index === state.stack.length - 1;
  state.stack.splice(index, 1);

  if (wasOnTop) {
    const newTop = state.stack.at(-1);
    if (newTop) newTop.isVisible = true;
  }
},

closeOverlay снимает верхний элемент. removeOverlay убирает конкретную модалку из любой позиции — нужно, когда модалка не на вершине (например, панель действий, скрытая стратегией Replace, должна уйти из стека при снятии выделения).

// Модалка видима прямо сейчас
export const useOverlayIsOpenSelector = (name: EOverlayName): boolean =>
  useSelector((state) =>
    state.overlays.stack.find((item) => item.name === name)?.isVisible ?? false
  );

// Модалка в стеке (может быть скрыта Replace)
export const useOverlayIsInStackSelector = (name: EOverlayName): boolean =>
  useSelector((state) =>
    state.overlays.stack.some((item) => item.name === name)
  );

// Типизированные данные
export const useOverlayDataSelector = <TName extends keyof TOverlayData>(
  name: TName,
): TOverlayData[TName] =>
  useSelector((state) => state.overlays.data[name]);

Слой 2: Хук useOverlays

type TOverlayDataProp<TName extends EOverlayName> =
  TOverlayData[TName] extends undefined
    ? { data?: undefined }
    : { data: TOverlayData[TName] };

type TOpenOverlayParams<TName extends EOverlayName> = {
  name: TName;
  strategy?: EOverlayStrategy;
} & TOverlayDataProp<TName>;

export const useOverlays = () => {
  const { openOverlay, closeOverlay, closeAllOverlays, setOverlayData } =
    useOverlaysActions();

  const open = useCallback(
    <TOverlayName extends EOverlayName>({
      name,
      data,
      strategy = EOverlayStrategy.Replace,
    }: TOpenOverlayParams<TOverlayName>) => {
      setOverlayData({ name, data } as TSetOverlayDataPayload);
      openOverlay({ overlayName: name, strategy });
    },
    [setOverlayData, openOverlay],
  );

  const close = useCallback(() => closeOverlay(), [closeOverlay]);
  const closeAll = useCallback(() => closeAllOverlays(), [closeAllOverlays]);

  return { open, close, closeAll };
};

Слой 3: Кастомный хук для каждой модалки

Каждая модалка оборачивает useOverlays в свой хук с простым API:

export const useEditProductOverlay = () => {
  const { open, close } = useOverlays();
  const isOpen = useOverlayIsOpenSelector(EOverlayName.EditProduct);
  const { productId } = useOverlayDataSelector(EOverlayName.EditProduct);

  const openModal = useCallback(
    (id: number) =>
      open({ name: EOverlayName.EditProduct, data: { productId: id } }),
    [open],
  );

  return { isOpen, productId, open: openModal, close };
};

Потребитель:

const { open } = useEditProductOverlay();
open(42);

Как это работает вместе

Три участника: триггер, модалка, страница.

Триггер — кнопка в глубине дерева. Только вызывает open():

const ProductActionsMenu = ({ product }: { product: TProduct }) => {
  const { open } = useOverlays();

  const handleAddIMEI = () => {
    open({
      name: EOverlayName.IdentFromRow,
      data: { modalType: EIdentModalType.IMEI, orderId: product.id },
    });
  };

  return (
    <DropdownMenu>
      <DropdownItem onClick={handleAddIMEI}>Внести IMEI</DropdownItem>
    </DropdownMenu>
  );
};

Никаких <IdentificationModal /> внутри. Никаких пропсов сверху. Никаких API-хуков. Компонент в пятом уровне вложенности вызывает open() напрямую.

Модалка — рендерится на уровне страницы через обёртку (разберём ниже).

Страница — рендерит обёртки без пропсов:

const ProductsPage = () => (
  <PageLayout>
    <ProductsToolbar />
    <ProductsTable />
    <IdentFromHeader />
    <IdentFromRow />
    <EditProductDrawer />
  </PageLayout>
);

Стратегии в действии

Сценарий: выделили товары → панель действий → «Внести маркировку» → модалка → нажал крестик → confirmation → отменил → модалка вернулась → закрыл → панель вернулась.

1. Выделили товары:
   stack: [{ name: BulkActions, isVisible: true }]

2. Нажали «Внести маркировку» (strategy: Replace):
   stack: [
     { name: BulkActions, isVisible: false },     ← скрыта, но в стеке
     { name: IdentFromHeader, isVisible: true },
   ]

3. Нажали крестик, открылся confirmation (strategy: Stack):
   stack: [
     { name: BulkActions, isVisible: false },
     { name: IdentFromHeader, isVisible: true },
     { name: Confirmation, isVisible: true },      ← обе видимы
   ]

4. Нажали «Отмена» в confirmation:
   stack: [
     { name: BulkActions, isVisible: false },
     { name: IdentFromHeader, isVisible: true },   ← вернулась
   ]

5. Закрыли модалку идентификации:
   stack: [
     { name: BulkActions, isVisible: true },       ← вернулась
   ]

Никакой ручной логики. Три модалки, цепочка Replace → Stack — и ни одного if. Стек сам знает, что вернуть.

Одна модалка, разные контексты: паттерн обёрток

Вернёмся к IdentificationModal — три места вызова, три разных API-адаптера.

UI-компонента: принимает API-адаптер, логика внутри

IdentificationModal принимает API-адаптер и базовые пропсы. Всю работу с формой, валидацией, временными данными берёт на себя внутренний хук:

type TIdentificationModalProps = {
  isOpen: boolean;
  modalType: EIdentModalType;
  api: TIdentOrdersModalApi;
  onClose: () => void;
};

const IdentificationModal = ({ isOpen, modalType, api, onClose }: TIdentificationModalProps) => {
  // Вся логика формы — внутри модалки
  const {...} = useIdentOrdersModal(api, modalType);

  return (
    <Drawer isOpened={isOpen} onClose={onRequestClose}>
      {/* таблица заказов, инпуты, кнопки — всё тут */}
    </Drawer>
  );
};

Обёртки: передают API-адаптер и базовые данные

Каждая обёртка слушает своё имя в стеке, использует свой API-адаптер и передаёт пропсы в чистый UI:

Из шапки таблицы (все заказы):

const IdentFromHeader = () => {
  const isOpen = useOverlayIsOpenSelector(EOverlayName.IdentFromHeader);
  const { modalType } = useOverlayDataSelector(EOverlayName.IdentFromHeader);
  const { close } = useOverlays();

  const api = useIdentOrdersApi();

  return (
    <IdentificationModal
      isOpen={isOpen}
      modalType={modalType}
      api={api}
      onClose={close}
    />
  );
};


Из строки таблицы:

const IdentFromRow = () => {
  const isOpen = useOverlayIsOpenSelector(EOverlayName.IdentFromRow);
  const { modalType, orderId } = useOverlayDataSelector(EOverlayName.IdentFromRow);
  const { close } = useOverlays();

  const api = useRowIdentOrdersApi(orderId);

  return (
    <IdentificationModal
      isOpen={isOpen}
      modalType={modalType}
      api={api}
      onClose={close}
    />
  );
};

Из корзины:

const IdentFromCart = () => {
  const isOpen = useOverlayIsOpenSelector(EOverlayName.IdentFromCart);
  const { modalType, cartId } = useOverlayDataSelector(EOverlayName.IdentFromCart);
  const { close } = useOverlays();

  const api = useCartIdentOrdersApi(cartId);

  return (
    <IdentificationModal
      isOpen={isOpen}
      modalType={modalType}
      api={api}
      onClose={close}
    />
  );
};

Каждая обёртка — 5 строк логики. Отличается только API-адаптер и данные из overlay.

Завершение

Не каждому проекту нужен типизированный стек с тремя стратегиями. Если у вас лендинг с одной формой обратной связи или дашборд с парой простых confirmation-диалогов — useState(false) хватит с головой. И это нормально.

Но есть проекты, где модальных окон — десятки. Админки, CRM-системы, маркетплейсы, внутренние инструменты. Модалки открываются из таблиц, карточек, вложенных меню, из других модалок. Одна и та же модалка используется в пяти местах с разными данными. При открытии одной нужно скрыть другую, а при закрытии — вернуть.

В таких проектах вопрос не в том, будет ли код превращаться в лапшу, а в том, когда. Каждая новая модалка — это ещё один useState, ещё один проп через три уровня, ещё один if в обработчике закрытия. По отдельности — мелочь. В сумме за полгода — код, в котором страшно что-то менять.

Архитектура из этой статьи — не единственный возможный подход. Но принципы, на которых она построена, универсальны:

  • Модалка рендерится один раз, а не в каждом месте вызова

  • Открытие и данные — через глобальный стейт, а не prop drilling

  • Видимость управляется стеком с правилами, а не ручными if-ами

  • Разная логика для разных контекстов — снаружи модалки, а не внутри

Если вы узнали свой проект в примерах из первой половины статьи — попробуйте начать с малого. Один enum, один слайс, один useOverlays(). Остальное добавится, когда станет нужно.