Вступление
Модальные окна — один из самых недооценённых слоёв 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 | После сохранения |
|---|---|---|---|---|
Шапка таблицы | Все заказы по типу маркировки |
|
| — |
Строка таблицы | Один заказ (фильтр по |
|
| — |
Шапка корзины | Заказы корзины |
|
| — |
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 } этого не знает. Это конечный автомат, размазанный по редьюсерам.
Итог: три проблемы, которые не решаются по отдельности
Рендеринг и доступ. Модалка должна рендериться один раз, но открываться из любого места дерева без prop drilling.
Разные контексты вызова. Одна модалка — разные данные, шаги, API, постобработка, аналитика, обработка ошибок. Засовывать в модалку — God Object из
if-ов. Выносить наружу — как разделить?Взаимозависимость. Открытие одной модалки должно скрывать другую, закрытие — возвращать. Ручное управление флагами не масштабируется.
Нужна система, которая решает все три сразу. Не набор isOpen-флагов — а стек с правилами, типизированными данными и стратегиями открытия.
Решение: типизированный стек с overlay-хуками
Redux-стек — единое хранилище с порядком и стратегиями открытия
Типизированные overlay-хуки — открытие из любого места без prop drilling
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(). Остальное добавится, когда станет нужно.
