Головна / Статті / Реактивні модали без перерендерингу сторінки: 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 опиняється у предку сторінки, кожне відкриття чи закриття призводить до повторного відображення всього підроздерева. На простих екранах цього ніхто не помічає. Але на графіках, віртуалізованих списках чи інших складних інтерфейсах натискання кнопки „Поділитися“ може спричинити затримки.

Додайте ще дві вимоги — діалогові вікна, які відкривають інші діалогові вікна (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: кожне відкриття додає справжню історію, тож функція Back закриває шари по одному — саме цього очікують користувачі від трьох вкладених діалогових вікон.

Іншими словами: розглядайте керування модальними вікнами як інфраструктуру, а не як стан користувацького інтерфейсу, що належить екрану. Екрани самостійно отримують дані та керують станом місцевих форм. Інфраструктура визначає, які накладення існують, у якому порядку та як панель адреси відображатиме цю структуру. Саме це розділення дозволяє залишати ресурсоемні елементи неактивними під час відкривання та закриття діалогових вікон.

Коли потрібне вкладення, краще використовувати прямі виклики 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 залишається зручним для поширення та користування з функцією «Повернутися», він точно відображає те, що відкрите, включаючи кілька рівнів.
  • Жоден з елементів не є чимось незвичайним — зовнішній сховище, портал, лежерне завантаження та синхронізація за допомогою pushState — це звичайні інструменти. Цікаве правило стосується архітектури: те, що вирішує, що має бути відкритим, ніколи не повинно бути предком того, що не має про це знати.

    Повна демонстрація Vite + React + TypeScript + Zustand із трьома вкладеними модалами та лічильником живого оновлення знаходиться на GitHub: react-modal-stack. Після виконання команд npm install && npm run dev перейдіть на «Поділитися» → «Переглянути коментарі» → «Відповісти», щоб переконатися, що сторінка позаду не оновлюється.