Рэактыўныя модалы без перзапісву сторанкі: Stack Store і Portal Manager
Зберагаюце стан модалных элементаў у стаку Zustand за межамі дрэва, адражнаюце іх за дапамою ModalManager і порталаў, а таксама сінхронізуюце вялікі дыялогі з URL за дапамою pushState.
Крыніцы: Building React Modals That Don’t Re-render Your App
TL;DR — Не паводзіце стан модалага ў дрэве компонентаў React. Храніце яго у вайшэйшым хранальніку (заўсёды Zustand) у фармаце стака, адрасавайце яго праз спецыяльны
ModalManager, які монтуюцца як братскі элемент аплікацыі (не як ўзроджэнны), і завантажайце тэксты модалага праз портал з вікорыстаннемlazy(). Тады адкрыць, закрыць чыя-небудзь дыялогі ніколі не будзе пераранжаваць сторунку пад імі; накладванне дыялога проста адбываецца праз дадаванне элемента ў масэвы стак; а URL застаецца актуальным дзякуючыpushState/popstate.
Тыповая першая сістэма модалага ў React выглядае так:
function App() {
const [modal, setModal] = useState(null);
// …rest of the app tree lives here
}
Яна працуе, пакуль не перэстане. Калі стан modal апынуцца ў ўзроджэнным элеменце сторункі, кожна дзеянне адкрыць чы закрыць пераранжавае цэлую паддрэва. На простым экране ніхто гэтага не зазначае. Але ў графіках, вярчуваных супаках чы іншай складной UI адкрыць опцыю “Падазліць” можа спрычыніць затрохканне працы.
Дадзіце ўсьмо два дапаможныя тэрэтарыў — дыялогі, якія ачынаюць іншыя дыялогі (share → comments → reply) і URL, які відбівае тое, што ў ачыненым стане (refresh, back, deep links) — і тады адзін 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: кожны новы адкрытыя дыялог дадае рэальную історыю, таму кнопка «Назад» закрывае адпаведны слой па штоўна — гэта тое, чаго спакушаюць корыстнікі ад трох вярстаных дыялогаў.
Іншымі словамі: трэба спрыяваць працы модальных дыялогоў як інфраструктуры, а не як стану UI, які належыць экрану. Экраны самі збіраюць даны і керуюць станам местных форм. Інфраструктура вялічыць, якія дыялогі ўздымаюцца, у какой спраўе і як панель адрэсы відбівае гэтыя структуры. Гэтае аддзеленне дапамагае захаваць рэсурсы бездзейнымі, калі дыялогі адкрываюцца і закрываюцца.
Калі неабходна вярстанасць, лепшае выкарыстоўванне явных вызоваў 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), рашаюць гэту проблему, дазволяючы компонентам сочыць за часткай дадзеных.
Модалкі ачываюцца самым некалькаматным моментам, каб заплатыць той податак. Лісты для аднараджэння, трыбы каментавання і падтверджэннія должны з’являцца мгновенна пасля нажатка. Затрымка, якая была б непазначальна на фоне таймера, становіцца явной, калі гэта прымусовая адпаведзь на нажатку.
Вы можэце мераваць, а не прымкнуцца да твароў. Дэма можа размістыць лічылку запускаў на сторанцы і ў кожным модалку: ачыненне, закрыцце, і пазірэце, як лічылка сторанцы застаецца незменнай, пакуль кожны модалк збірае даны самастойна. React DevTools Profiler паказвае тое ж — праца ведзецца ўнутрь порталу, а не ўнутрь дрэва прыкладзеных дапаможных програм.
Не кожны дыялогі трэбуе гэтага точнага фармата. Статычная падтверджэння без вкладання на дэшавай сторонцы не будзе практычна з точкі зору структуры. Гэта мае значэнне, калі сторонцы пад модальным вікном трэба дорага перэрысаваць, калі існуе рэальна структура вкладання, або калі стан відкрытага вікна павінен застацца пасля апдэйта за дапамою URL. У такім случае ігнораванне ізоляцыі не ўявна проблема: гэта значыць перэрысаванне всіго старонка ў кожны раз, калі вікно відкрываецца чы роўнаецца, для кожнага карыстальніка.
Калі команда ужо стандартызавалася на Radix чы Vaul для адпаведнасці адзінам людзям, спачатку выкарыстоўваеце гэтыя фрэймворкі, і толькі пасля таго, калі аналізаторы пакажуць вялікую кантрольную працу на сторонцы пад час відкрывання/закрыцця, або калі продукт выкарыстоўвае структуру з вкладанням з адпаведнасцю да кнопкі «Назад», тады можна ввесты сістэму хранення даных. Нерэшанае стварэнне інфраструктуры — рэальная проблема; так сама як і неабходнасць перэрысавання всіх элементаў старонка ў кожны раз, калі нажымаецца кнопка «Падазліць», калі панель керування зайнята.
Што вы отрымаеце ад усьго гэтаго
- Відкрыванне чы закрыцце будзь-яга модальнага вікна на будзь-яй глыбі ніколі не спрычыняе перэрысавання старонка — толькі шар модальнага вікна чытае яго стан.
Няхто з элементаў не ёсць экзотычным — зовнішняй хранальнік, портал, атрыбут lazy loading і сінхронізацыя за дапамой pushState — гэта стандартныя інструменты. Цікавая правіла — архітэктурная: тое, што вырашае, што ўвімкнэта, ніколі не можа быць прэдакам таго, што не павінна пры ўвімкнэнні рэагаваць.
Полная дамаўка Vite + React + TypeScript + Zustand з трыма вкладзенымі модалнымі вікнамі і лічыльнікам рэндараў ўжо існуе на GitHub: react-modal-stack. Пасля запуску npm install && npm run dev перайдзіце на раздзел Share → View comments → Reply, і вы пабачыце, што сторонняя сторунка ніколі не перарэндаруецца.