Вступление
Модальные окна — один из самых недооценённых слоёв 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 {
orders, metaErrors, isOnlyUnfilledOrders,
updateMetaValue, toggleUnfilledOrders,
validateOrdersAndSaveMeta, uploadFile, downloadTemplate,
onRequestClose, onConfirmClose, onCancelClose, isConfirmOpen,
} = 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.
Триггеры
// Строка таблицы
const { open } = useCheckoutOverlay();
<Button onClick={() => open({ source: 'row', product })}>Оформить</Button>
// Панель массовых действий
<Button onClick={() => open({ source: 'bulk', products: selectedProducts })}>
Оформить выбранные
</Button>
// Карточка корзины
<Button onClick={() => open({ source: 'cart', cartId: cart.id })}>
Оформить корзину
</Button>
На странице
const ProductsPage = () => (
<PageLayout>
<ProductsToolbar />
<ProductsTable />
<CheckoutFromRow />
<CheckoutFromBulk />
<CheckoutFromCart />
<EditProductDrawer />
</PageLayout>
);
Завершение
Не каждому проекту нужен типизированный стек с тремя стратегиями. Если у вас лендинг с одной формой обратной связи или дашборд с парой простых confirmation-диалогов — useState(false) хватит с головой. И это нормально.
Но есть проекты, где модальных окон — десятки. Админки, CRM-системы, маркетплейсы, внутренние инструменты. Модалки открываются из таблиц, карточек, вложенных меню, из других модалок. Одна и та же модалка используется в пяти местах с разными данными. При открытии одной нужно скрыть другую, а при закрытии — вернуть.
В таких проектах вопрос не в том, будет ли код превращаться в лапшу, а в том, когда. Каждая новая модалка — это ещё один useState, ещё один проп через три уровня, ещё один if в обработчике закрытия. По отдельности — мелочь. В сумме за полгода — код, в котором страшно что-то менять.
Архитектура из этой статьи — не единственный возможный подход. Но принципы, на которых она построена, универсальны:
-
Модалка рендерится один раз, а не в каждом месте вызова
-
Открытие и данные — через глобальный стейт, а не prop drilling
-
Видимость управляется стеком с правилами, а не ручными
if-ами -
Разная логика для разных контекстов — снаружи модалки, а не внутри
Если вы узнали свой проект в примерах из первой половины статьи — попробуйте начать с малого. Один enum, один слайс, один useOverlays(). Остальное добавится, когда станет нужно.
Автор: undefinet
