Галоўная / Артыкулы / Рэактыўныя модалы без перзапісву сторанкі: Stack Store і Portal Manager

Рэактыўныя модалы без перзапісву сторанкі: Stack Store і Portal Manager

Зберагаюце стан модалных элементаў у стаку Zustand за межамі дрэва, адражнаюце іх за дапамою ModalManager і порталаў, а таксама сінхронізуюце вялікі дыялогі з URL за дапамою pushState.

2162 слоў

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 плюс рэальнай історыі викорыстання.
  • Next.js з’явленням на шляхах / паралельных шляхах рашваёць проблему сінхронізацыі URL на роўні фрамворку: модальна вікно ёсць частка шляху, яка дадае можлівасць прадстаўлення іншага кантэнту на старонцы. У App Router гэта часта є болей ефектывным спосабам, чым викорыстоўванне ручна напісанага 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 для адпаведнасці адзінам людям, спачатку выкарыстоўваеце гэтыя фреймворкі, і толькі пасля таго, калі аналізаторы пакажуць вялікую кантрольную роботу на сторонцы пад час відкрывання/закрыцця, або калі продукт выкарыстоўвае структуру з вкладанням з адпаведнасцю да кнопкі «Назад», тады можна ввести систему хранення даных. Нерэшанае стварэнне інфраструктуры — рэальная проблема; так сама як і неабходнасць перерысавання всіх элементаў сторонка ў кожны раз, калі нажымаецца кнопка «Падазліць», калі панель керування зайнята.

    Што вы отрымаеце ад усьго гэтаго

    • Відкрыванне чы закрыцце будзь-яга модальнага вікна на будзь-яй глыбі ніколі не спрычыняе перерысавання сторонка — толькі шар модальнага вікна чытае його стан.
  • Рэндарынг модалных вікнаў адбываецца структурна: праблемы з арэйямі, а не як спецыяльны случай.
  • Кожна частка модалнага вікна ўскладнена і завантажваецца за патрэбай.
  • URL застаецца чытаемым і зручным для аднароджэння паказаннем таго, што ўвімкнэта, уключаючы калькі слоёў.
  • Няхто з элементаў не ёсць экзотычным — зовнішняй хранальнік, портал, атрыбут lazy loading і сінхронізацыя за дапамой pushState — гэта стандартныя інструменты. Цікавая правіла — архітектурная: тое, што вырашае, што ўвімкнэта, ніколі не можа быць предакам таго, што не павінна пры ўвімкнэнні рэагаваць.

    Полная дамаўка Vite + React + TypeScript + Zustand з трыма вкладзенымі модалнымі вікнамі і лічыльнікам рэндараў ўжо існуе на GitHub: react-modal-stack. Пасля запуску npm install && npm run dev перайдзіце на раздел Share → View comments → Reply, і вы пабачыце, што сторонняя сторунка ніколі не перарэндаруецца.