Реактивные модалы без перерисовки страницы: Stack Store и Portal Manager
Храните состояние модалок в стеке Zustand вне дерева, отображайте их с помощью ModalManager и порталов-братьев, а также синхронизируйте вложенные диалоговые окна с URL с использованием pushState.
Кратко — не включайте состояние модалок в дерево компонентов React. Храните его в виде стека во внешнем хранилище (здесь — Zustand), отображайте его с помощью специального
ModalManager, который монтируется как *братский* элемент приложения (а не его предок), и загружайте содержимое модалок через портал с использованиемlazy(). При открытии, закрытии или вложении диалоговых окон страница больше не перерисовывается; вложение осуществляется просто путем добавления элемента в массив; а URL остается актуальным благодаряpushState/popstate.
Типичная первая система модалок в React выглядит так:
function App() {
const [modal, setModal] = useState(null);
// …rest of the app tree lives here
}
Она работает до тех пор, пока что-то идет не так. Как только состояние modal оказывается у предка страницы, каждое действие по открытию или закрытию приводит к перерисовке всего соответствующего поддерева. На простом экране этого не заметно, но при использовании графиков, виртуализированных списков или других сложных интерфейсов нажатие на кнопку «Поделиться» может вызвать задержки.
Добавьте ещё два требования — диалоговые окна, открывающие другие диалоговые окна (раздел «Сообщения» → «Комментарии» → «Ответить») и URL, отражающий содержимое открытого окна (функции «Обновить», «Назад», глубокие ссылки) — и тогда один-единственный объект useState перестаёт быть просто ресурсоёмким и становится сложным для понимания.
Представленная ниже архитектура изолирует эти проблемы.
Основная идея: вынести состояние модальных окон за пределы дерева отрисовки
Проблема повторной отрисовки связана с тем, где находится состояние. Флаги модальных окон, принадлежащие предку, заставляют поддерево этого предка обновляться при любых изменениях.
// modalStore.ts
import { create } from 'zustand';
export interface ModalEntry {
id: string;
key: string;
props?: Record<string, unknown>;
urlParams?: Record<string, string>;
}
interface ModalState {
stack: ModalEntry[];
push: (entry: Omit<ModalEntry, 'id'> & { id?: string }) => string;
pop: () => void;
popById: (id: string) => void;
closeAll: () => void;
setStack: (stack: ModalEntry[]) => void;
}
let counter = 0;
const nextId = () => `modal_${Date.now()}_${counter++}`;
export const useModalStore = create<ModalState>((set, get) => ({
stack: [],
push: (entry) => {
const id = entry.id ?? nextId();
set((s) => ({ stack: [...s.stack, { ...entry, id }] }));
return id;
},
pop: () => set((s) => ({ stack: s.stack.slice(0, -1) })),
popById: (id) => set((s) => ({ stack: s.stack.filter((m) => m.id !== id) })),
closeAll: () => set({ stack: [] }),
setStack: (stack) => set({ stack }),
}));
Важны два момента. Во-первых, состояние представляет собой стек, а не отдельный слот — именно это позволяет свободно использовать вложенность. Во-вторых, речь идет о хранилище Zustand, а не о React Context. Context уведомляет всех потребителей; Zustand позволяет компоненту выбрать определенный фрагмент состояния, так что перерисовывается только соответствующий подписчик. Для решения общей проблемы вида «что открыто?» именно в этом заключается разница.
Открытие модального окна не должно влиять напрямую на состояние React
Функции для открытия и закрытия читают и записывают данные из хранилища с помощью getState(), а не через useModalStore():
// modalActions.ts
export function openModal(key: string, params: Record<string, string>) {
const meta = modalRegistry[key];
if (!meta) return;
useModalStore.getState().push({
key,
urlParams: params,
props: meta.fromParams(params),
});
}
export function closeModal(id: string) {
useModalStore.getState().popById(id);
}
Поскольку функция openModal никогда не вызывает хук, её выполнение не приводит к выбору хранилища и само по себе не перерисовывает ничего — только обновление хранилища это делает, и только те компоненты, которые подключены к этому хранилищу, это ощущают. Вызывайте openModal(...) из любого обработчика клика, включая те, что находятся внутри другого модала, и единственным элементом реактора будет компонент, ответственный за реакцию.
Единственный компонент, которому разрешено реагировать
// ModalManager.tsx
export function ModalManager() {
const stack = useModalStore((s) => s.stack);
const root = usePortalRoot('modal-root');
if (!root || stack.length === 0) return null;
return createPortal(
<>
{stack.map((entry, index) => {
const meta = modalRegistry[entry.key];
if (!meta) return null;
const Component = meta.component;
const isTop = index === stack.length - 1;
const Shell = meta.kind === 'sheet' ? BottomSheetShell : ModalShell;
return (
<Shell key={entry.id} depth={index} isTop={isTop} onClose={() => closeModal(entry.id)}>
<Suspense fallback={<div className="modal-loading">Loading…</div>}>
<Component {...entry.props} onClose={() => closeModal(entry.id)} />
</Suspense>
</Shell>
);
})}
</>,
root
);
}
ModalManager подключается один раз рядом с <App />, а не внутри него:
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ModalManager />
<ModalUrlSync />
</StrictMode>,
);
Именно такая связь братьев определяет архитектуру. Если бы ModalManager оборачивал <App />, каждое изменение стека приводило бы к перерисовке самого менеджера и его дочерних элементов, включая приложение. Будучи братьями под одним корнем, App никогда не узнаёт о произошедших обновлениях.
Вложенные модалы — это просто более длинный массив
Когда состояние представляет собой стек, конфигурация «модальное окно внутри модального окна» не является особым случаем — это стандартное поведение функции push.
Диалоговое окно для обмена может открыть форму комментариев, которая в свою очередь может открыть диалоговое окно для ответа; каждый из этих элементов добавляет новую запись:
// Inside ShareModal
<button onClick={() => openModal('comments', { productId })}>
View comments
</button>
// Inside CommentSheet
<button onClick={() => openModal('reply', { commentId })}>
Reply
</button>
Каждый уровень имеет собственное значение depth (для определения z-index) и закрывается с помощью собственного id. Закрытие диалога для ответа оставляет комментарии и возможность обмена нетронутыми. Не требуется рекурсивные компоненты модальных окон или специальная машина состояний — достаточно массива с тремя элементами.
Сохранение простоты и кэширования «оболочки»
Оболочка модального окна — накладная плоскость, карточка, анимация, обработка нажатия клавиши Escape — остается отдельной от содержимого и оборачивается функцией React.memo:
export const ModalShell = memo(function ModalShell({ depth, isTop, onClose, children }) {
useEffect(() => {
if (!isTop) return; // only the topmost modal reacts to Escape
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isTop, onClose]);
return (
<div className="modal-overlay" style={{ zIndex: 1000 + depth }} onMouseDown={/* close on backdrop click */}>
<div className="modal-card" role="dialog" aria-modal="true">
<button className="modal-close" onClick={onClose} aria-label="Close">×</button>
{children}
</div>
</div>
);
});
Только самый верхний слой обрабатывает нажатие клавиши Escape; в противном случае одно нажатие могло бы закрыть все слои. Поскольку слой не зависит от содержимого, каждый элемент загружается с использованием функции lazy() для каждой записи реестра, чтобы редкий диалог ответа не увеличивал размер начального пакета:
export const modalRegistry: Record<string, ModalRegistryItem> = {
share: { component: lazy(() => import('./examples/ShareModal')), kind: 'modal', ... },
comments: { component: lazy(() => import('./examples/CommentSheet')), kind: 'sheet', ... },
reply: { component: lazy(() => import('./examples/ReplyModal')), kind: 'modal', ... },
};
export const modalRegistry: Record<string, ModalRegistryItem> = {
share: { component: lazy(() => import('./examples/ShareModal')), kind: 'modal', ... },
comments: { component: lazy(() => import('./examples/CommentSheet')), kind: 'sheet', ... },
reply: { component: lazy(() => import('./examples/ReplyModal')), kind: 'modal', ... },
};
В производственной версии каждое модальное окно становится отдельным блоком, который загружается только тогда, когда открывается соответствующая запись.
Причина, по которой URL должен отражать реальное состояние
Сохранение синхронизации между полем адреса и стеком представляет собой проблему синхронизации между хранилищем Zustand и объектом window.location. Если это сделать неправильно, возникнет бесконечный цикл или функция «Назад» будет покидать страницу вместо того, чтобы закрыть диалог.
Булевый реф обозначает, что «это изменение произошло из URL, его не следует записывать обратно»:
export function useModalUrlSync() {
const setStack = useModalStore((s) => s.setStack);
const stack = useModalStore((s) => s.stack);
const syncingFromUrl = useRef(false);
// stack -> URL
useEffect(() => {
if (syncingFromUrl.current) { syncingFromUrl.current = false; return; }
const serialized = serializeStack(stack);
const params = new URLSearchParams(window.location.search);
serialized ? params.set('modals', serialized) : params.delete('modals');
window.history.pushState({ modals: serialized }, '', `${window.location.pathname}?${params}`);
}, [stack]);
// URL -> stack (back/forward button)
useEffect(() => {
const onPopState = () => {
syncingFromUrl.current = true;
const raw = new URLSearchParams(window.location.search).get('modals') ?? '';
setStack(deserializeStack(raw));
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, [setStack]);
}
Лучше использовать pushState вместо replaceState: каждое открытие добавляет реальную историю, поэтому кнопка «Назад» закрывает слой за слоем — именно этого ожидают пользователи при работе с тремя вложенными диалоговыми окнами.
Другими словами: рассматривайте управление модальными окнами как инфраструктуру, а не как состояние интерфейса, принадлежащее конкретному экрану. Экраны сами занимаются загрузкой данных и хранением локального состояния форм. Инфраструктура определяет, какие окна находятся друг над другом, в каком порядке, и как строка адреса отражает эту структуру. Именно такое разделение позволяет дорогостоящим компонентам оставаться неактивными во время открытия и закрытия диалоговых окон.
Когда требуется вложенность, предпочтительнее использовать явные вызовы openModal изнутри контента, а не булевые флаги, передаваемые через родительские элементы. Флаги создают взаимосвязь: каждый родитель должен знать о каждом дочернем диалоговом окне. Использование ключа реестра вместе с параметрами позволяет родителю не знать внутренних деталей дочернего элемента и делает менеджера ответственным за порядок размещения окон и записи в истории.
Как это сочетается с существующими библиотеками
Этот паттерн не заменяет существующую экосистему модалок; он решает проблему расположения состояния. Совпадения с распространёнными инструментами:
- Radix UI
Dialogи Vaul отличаются высокой доступностью и поддержкой жестов — блокировка фокуса, запрет на прокрутку, закрытие путём перетаскивания. Они не устанавливают строгих правил относительно местоположения информации о «открытых» модалах, поэтому любой из них может служить здесьShell. Системы хранения и порталы находятся ниже, а не вместо них. - NiceModal (
@ebay/nice-modal-react) предназначен для прямого вызова функций показа/скрытия (NiceModal.show(MyModal)) без использования логических операторов. Он подходит в ситуациях, когда синхронизация по URL и глубокое вложение модалок не требуются. Дизайн с использованием стека и системы хранения ближе к принципам работы NiceModal плюс наличие реальной истории операций.
pushState. Подход с хранилищем данных основан на той же идее для приложений Vite/CRA React или старых версий Pages Router, не имеющих встроенных механизмов перехвата маршрутов.Если библиотека уже обрабатывает функции Chrome, следует использовать её базовые компоненты в качестве основы, сохраняя при этом хранилище данных и ModalManager для обеспечения изоляции и вложенности элементов.
Необходимо ли всё это? Использование топ-уровневого useState в компоненте App позволяет сократить количество строк кода и подходит для многих проектов. Необходимо четко указывать, какую пользу приносит внешняя библиотека, и почему утверждение о «меньшем количестве перерисовок» не является расплывчатым маркетинговым слоганом.
Затраты на согласование растут вместе с поддеревом, а не в зависимости от малости изменения состояния. Когда компонент перерисовывается, React обрабатывает все немемоизированные потомки, даже если разница в DOM крайне незначительна. Изменение значения modalOpen в компоненте App — это не просто отображение диалогового окна; это повторная обработка всех функций от App до его листовых элементов, пересчёт производных значений, повторная проверка использования useMemo, а также перезапуск эффектов, зависимости которых изменились. На простой странице это практически не заметно. Однако в таблице, графике, мощном редакторе или длинном виртуализированном списке это приводит к задержке от мгновенного появления элемента до замедленной реакции на одну-две кадры.
Радиус взрыва определяется местоположением, а не размером загружаемого контента. Булево значение и стек из пяти элементов занимают примерно одинаковое количество памяти; разница заключается в том, кому отправляются уведомления. Перенос состояния модального окна во внешний хранилище, которое читает только ModalManager, позволяет сократить область влияния с всей страницы до одного отдельного компонента. Никакие другие элементы не смогут это заметить, если только они не отслеживают именно этот фрагмент.
Контекст — не решение, даже если кажется таковым. Контекст устраняет необходимость передачи пропсов по иерархии, но не предотвращает повторную отрисовку. Каждый потребитель функции useContext обновляется при изменении значения, даже если он игнорирует конкретное поле, которое изменилось. Корневой элемент <ModalProvider> воссоздаёт первоначальный радиус влияния с использованием другого API. Хранилища на основе селекторов (Zustand, Jotai, Redux selectors) решают эту проблему, позволяя компонентам отслеживать отдельный фрагмент данных.
Модальные окна открываются в самый неподходящий момент для уплаты налога. Формы для обмена данными, потоки комментариев и подтверждения должны открываться сразу после нажатия. Задержка отображения, которая была бы незаметна на фоне таймера, становится очевидной, когда речь идет о мгновенном ответе на нажатие.
Вы можете измерять результаты, вместо того чтобы верить заявлениям. В демо-версии можно разместить счетчик отрисовок на странице и в каждом модальном окне: при открытии, вложении или закрытии окна счетчик страницы остается неизменным, в то время как счетчик каждого модального окна увеличивается отдельно. Инструмент Profiler в React DevTools показывает то же самое — работа происходит внутри портала, а не внутри дерева приложений-собратьев.
Не каждый диалог требует именно такой структуры. Статическое подтверждение без вложенности на простой странице не оправдывает использования сложной структуры. Это важно, когда страница под модальным окном требует длительной перерисовки, когда существуют реальные вложенности или когда состояние открытия должно сохраняться после обновления страницы по URL. В таких случаях игнорирование принципа изоляции — не теория: это полная перерисовка всей страницы при каждом открытии и закрытии для всех пользователей.
Если команда уже использует Radix или Vaul для обеспечения доступности, сначала применяйте именно эти решения, внедряя схему хранения данных только тогда, когда инструменты анализа показывают значительную нагрузку на обработку страницы при открытии/закрытии или когда продукт требует вложенных операций с сохранением состояния при нажатии кнопки «Назад». Преждевременное внедрение инфраструктуры — реальная проблема; так же как и необходимость полной перерисовки всей структуры при каждом действии «Поделиться», когда панель управления находится в активном состоянии.
Что вы получаете в итоге
- Открытие или закрытие любого модального окна на любой глубине никогда не приводит к перерисовке всей страницы — только слой модального окна считывает его состояние.
Ни один из элементов не является чем-то экзотическим — внешний хранилище, портал, отложенная загрузка и синхронизация с помощью pushState — это обычные инструменты. Интересное правило касается архитектуры: то, что определяет, что открыто, никогда не должно быть предком того, что не должно об этом знать.
Полная демонстрация Vite + React + TypeScript + Zustand с тремя вложенными модалами и счётчиком рендеринга доступна на GitHub: react-modal-stack. После выполнения команд npm install && npm run dev перейдите на вкладку «Поделиться» → «Просмотреть комментарии» → «Ответить», чтобы убедиться, что страница сзади больше не рендерится.