Strona główna / Artykuły / Modały w React bez ponownego renderowania strony: Stack Store i Portal Manager

Modały w React bez ponownego renderowania strony: Stack Store i Portal Manager

Przechowuj stan modalny w stosie Zustand poza drzewem, renderuj go za pomocą ModalManager i portali będących braćmi, a zsynchronizuj nawarstwione okna dialogowe z adresem URL za pomocą pushState.

2162 słów

TL;DR — Unikaj przechowywania stanu modali w drzewie komponentów React. Zachowuj go jako stos w zewnętrznym magazynie danych (np. Zustand), renderuj go za pomocą dedykowanego ModalManager, który jest montowany jako siostrzany element aplikacji (a nie jej przodek), a treści modali ładowaj przez portal przy użyciu funkcji lazy(). Dzięki temu otwieranie, zamykanie lub układanie dialogów wewnątrz innych nie powoduje ponownego renderowania strony; układanie polega po prostu na dodawaniu elementów do tablicy, a adres URL pozostaje aktualny dzięki funkcjom pushState / popstate.

Powszechny pierwszy system modali w React wygląda tak:

function App() {
 const [modal, setModal] = useState(null);
 // …rest of the app tree lives here
}

Działa dobrze, dopóki tak nie przestanie. Gdy stan modal znajduje się na przodku strony, każde otwieranie lub zamykanie powoduje ponowny render całego tego poddrzewa. Na prostych ekranach nikt tego nie zauważy, natomiast przy wykresach, listach wirtualizowanych lub innych złożonych interfejsach kliknięcie „Podzielić się” może powodować opóźnienia.

Dodaj jeszcze dwa wymagania — dialogi, które otwierają inne dialogi (share → comments → reply) oraz adres URL, który odzwierciedla to, co jest otwarte (refresh, back, deep links) — a pojedynczy useState przestaje być jedynie marnotrawstwem zasobów i staje się trudny do zrozumienia.

Poniższa architektura izoluje te problemy.

Główna idea: usunięcie stanu modalnego z drzewa renderowania

Koszt ponownego renderowania wynika z miejsca, w którym znajduje się stan. Flagi modalne należące do przodka zmuszają poddrzewo tego przodka do aktualizacji przy każdej zmianie.

// 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 }),
}));

Dwa aspekty są tutaj ważne. Po pierwsze, stan jest reprezentowany jako stack, a nie pojedyncza pozycja – to właśnie umożliwia wolne nawijanie komponentów. Po drugie, chodzi tu o magazyn Zustand, a nie React Context. Context powiadamia wszystkie komponenty korzystające ze stanu; Zustand pozwala komponentowi wybrać konkretną część stanu, dzięki czemu tylko ten komponent jest ponownie renderowany. W przypadku kwestii typu „co jest otwarte?”, to właśnie ta różnica ma znaczenie.

Otwieranie modalu nie powinno bezpośrednio wpływać na stan React

Pomocniki do otwierania i zamykania modali czytają oraz zapisują dane w magazynie za pomocą getState(), a nie 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);
}

Ponieważ openModal nigdy nie wywołuje tego hooka, jego uruchomienie nie powoduje wyboru sklepu ani samodzielnej ponownej renderizacji niczego — tylko aktualizacja sklepu to robi, i tylko komponenty, które włączyły się do tego sklepu, to odczuwają. Wywołaj openModal(...) z dowolnego obsługującego kliknięcie funkcji — włącznie z wnętrza innego modalu — a jedynym reaktorem jest komponent, którego zadaniem jest reakcja.

Jeden komponent, który może się tym przejmować

// 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 jest montowany raz obok <App />, a nie w jego wnętrzu:

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
    <ModalManager />
    <ModalUrlSync />
  </StrictMode>,
);

To pokrewieństwo między elementami określa architekturę. Gdyby ModalManager otaczał <App />, każda zmiana w stosie składałaby się z ponownej renderizacji menedżera, a następnie jego dzieci — włącznie z aplikacją. Jako elementy siostrzane pod jednym korzeniem, App nigdy nie otrzymuje informacji o aktualizacji.

Zagnieżdżone modale to po prostu dłuższy tabliczka

Gdy stan jest stosem, „modal wewnątrz modala” nie stanowi przypadku specjalnego – to standardowe zachowanie funkcji push.

Okno udostępniania może otworzyć kartę z komentarzami, a ta z kolei może otworzyć okno odpowiedzi; każde z nich dodaje kolejną pozycję:

// Inside ShareModal
<button onClick={() => openModal('comments', { productId })}>
  View comments
</button>
// Inside CommentSheet
<button onClick={() => openModal('reply', { commentId })}>
  Reply
</button>

Każda warstwa ma swój własny depth (dla z-index) i jest zamykana za pomocą własnego id. Zamknięcie okna odpowiedzi pozostawia komentarze i funkcję udostępniania nietknięte. Nie ma żadnych rekurencyjnych komponentów modalowych ani specjalnej maszyny stanu – tylko tablica z trzema elementami.

Zachowanie prostoty i zapamiętywanie wartości

Shell modala – warstwa nakładająca się, karta, animacje, obsługa klawisza Escape – pozostaje oddzielona od treści i jest otoczona funkcją 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>
  );
});

Tylko najwyższa warstwa nasłuchuje klawisza Escape; w przeciwnym razie jeden naciśnięty klawisz mógłby spróbować zamknąć wszystkie warstwy. Ponieważ ta warstwa jest niezależna od zawartości, każda z nich ładowana jest za pomocą lazy() dla poszczególnych wpisów rejestru, dzięki czemu rzadko pojawiające się okna dialogowe nie powodują zwiększenia rozmiaru początkowego pliku:

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', ... },
};

Budowa wersji produkcyjnej potwierdza, że każde okno modalne staje się oddzielnym elementem, który ładowany jest tylko w momencie otwarcia danego wpisu.

Zmuszanie URL do mówienia prawdy

Połączenie paska adresu z stosem stanów to problem synchronizacji pomiędzy magazynem Zustand a window.location. Błąd w tym podejściu może spowodować nieskończony cykl lub to, że przycisk „Wstecz” opuści stronę zamiast zamknąć okno dialogowe.

Referencja typu boolean wskazuje, że „ta zmiana pochodzi z URL, nie należy jej zapisywać z powrotem”:

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]);
}

Lepiej używać pushState zamiast replaceState: każde otwarcie dodaje prawdziwą historię, dzięki czemu przycisk „Wstecz” zamyka warstwy po kolei – tak jak oczekują użytkownicy przy trzech nawarstwionych dialogach.

Innymi słowy: traktuj organizację modaliów jako infrastrukturę, a nie jako stan interfejsu należący do ekranu. Ekrany samodzielnie pobierają dane i zarządzają lokalnym stanem formularzy. Infrastruktura decyduje, które nakładki istnieją, w jakiej kolejności oraz w jaki sposób pasek adresu odzwierciedla tę strukturę. To rozdzielenie sprawia, że kosztowne elementy pozostają nieaktywne podczas otwierania i zamykania dialogów.

Gdy konieczne jest nawarstwianie, lepiej używać wyraźnych wywołań openModal bezpośrednio z treści, zamiast flag logicznych przekazywanych przez elementy nadrzędne. Flagi powodują powiązania: każdy element nadrzędny musi znać informacje o każdym dialogu potomnym. Klucz rejestru wraz z parametrami pozwala elementowi nadrzędnemu nie być świadomym wewnętrznych szczegółów dialogu potomnego i umożliwia menedżerowi kontrolę kolejności nawarstwiania oraz wpisów do historii.

Jak to wpisuje się w istniejące biblioteki

To rozwiązanie nie zastępuje ekosystemu modali; poprawia umiejscowienie stanu. Pokrywa się z powszechnymi narzędziami:

  • Radix UI Dialog oraz Vaul doskonale radzą sobie z dostępnością i gestami — blokowanie uwagi, blokada przewijania, zamknięcie poprzez przeciągnięcie. Nie narzucają określonego miejsca przechowywania informacji o otwartych modalach, więc każde z nich może pełnić rolę Shell. Systemy typu Store, portal i stack znajdują się poniżej nich, a nie zamiast nich.
  • NiceModal (@ebay/nice-modal-react) umożliwia wyświetlanie i ukrywanie modali za pomocą poleceń typu imperative (NiceModal.show(MyModal)) bez użycia wartości logicznych. Nadaje się w sytuacjach, gdy synchronizacja URL i głębokie nawijanie nie są konieczne. Projekt oparty na stacku i Store jest bliższy ergonomii NiceModal plus posiada prawdziwy kontekst historyczny.
  • Next.js intercepting / parallel routes rozwiązują problem synchronizacji URL na poziomie frameworka: modal to segment trasy wewnątrz strony. W App Routerze jest to często bardziej naturalne rozwiązanie niż ręczne używanie pushState. Podejście z użyciem store’a opiera się na tej samej zasadzie w aplikacjach Vite/CRA React lub starszych aplikacjach Pages Router bez wbudowanych tras intercepcji.
  • Jeśli biblioteka już obsługuje Chrome, należy zachować jej podstawowe funkcje jako „skorupy” i używać store’a wraz z ModalManager w celu izolacji i układania elementów warstwowo.

    Czy cokolwiek z tego jest konieczne? Użycie useState na najwyższym poziomie w komponencie App wymaga mniej linijek kodu i wystarcza dla wielu produktów. Należy precyzyjnie określić, co daje zewnętrzna biblioteka – i dlaczego „mniej ponownych renderowań” to nie jest mglista reklama.

    Koszt pojednania rośnie wraz z poddrzewem, a nie w zależności od tego, jak mała była zmiana stanu. Gdy komponent jest ponownie renderowany, React przegląda nepamietowane potomki, nawet jeśli różnica w DOM jest znikoma. Zmiana wartości modalOpen w komponencie App to nie „tylko wyświetlenie okna dialogowego”; powoduje ponowne uruchomienie wszystkich funkcji pomiędzy App a jego elementami końcowymi, ponowny obliczanie pochodnych, ponowną weryfikację zmiennych z użyciem useMemo oraz ponowne uruchomienie efektów, których zależności uległy zmianie. Na stronie o niewielkiej objętości jest to niewidoczne. Natomiast w tabeli, wykresie, zaawansowanym edytorze lub długiej wirtualizowanej liście jest to różnica pomiędzy natychmiastowym wyświetleniem a opóźnieniem trwającym jedną lub dwie klatki.

    To położenie określa zasięg oddziaływania, a nie rozmiar ładunku. Booleowska wartość i stos z pięcioma elementami wymagają podobnej ilości pamięci; kluczowy jest fakt, kto otrzymuje powiadomienie. Przeniesienie stanu modalu do zewnętrznego magazynu, który jest czytany wyłącznie przez ModalManager, zmniejsza obszar wpływu „otwartego modalu” z całej strony na jeden dedykowany komponent. Nic innego nie może tego zauważyć, chyba że wybrało ten konkretny fragment.

    Kontekst nie jest rozwiązaniem, nawet jeśli tak się wydaje. Kontekst eliminuje problem przenoszenia wartości między komponentami, ale nie zapobiega ponownym renderowaniom. Każdy użytkownik funkcji useContext aktualizuje się, gdy zmienia się wartość, nawet jeśli ignoruje pole, które uległo zmianie. Korzeń <ModalProvider> tworzy ponownie pierwotny zasięg oddziaływania przy użyciu innej API. Magazyny oparte na selektorach (Zustand, Jotai, Redux selectors) rozwiązują ten problem, pozwalając komponentom śledzić określony fragment danych.

    Okna modalne otwierają się w najgorszym momencie, by zapłacić ten podatek. Arkusze udostępniania, wątki komentarzy oraz potwierdzenia pojawiają się po kliknięciu i powinny działać natychmiastowo. Opóźnienie w renderowaniu, które byłoby niewidoczne przy tle z timerem, staje się oczywiste, gdy jest to bezpośrednia odpowiedź na kliknięcie.

    Można mierzyć, zamiast ufać deklaracjom. Dema może umieścić licznik renderowania na stronie oraz w każdym oknie modalnym: przy otwieraniu, układaniu się warstwowo i zamykaniu, przy czym licznik strony pozostaje niezmieniony, podczas gdy każde okno modalne zwiększa swój licznik osobno. React DevTools Profiler pokazuje to samo – praca odbywa się wewnątrz portalu, a nie wewnątrz drzewa aplikacji.

    Nie każdy dialog wymaga dokładnie takiej struktury. Statyczne potwierdzenie bez żadnego nawijania na taniej stronie nie uzasadnia tak skomplikowanej architektury. Ma to znaczenie wtedy, gdy stronę pod modalem trudno jest ponownie wyrenderować, gdy nawijanie jest rzeczywiste lub gdy stan otwartego modalu musi zostać zachowany po odświeżeniu strony za pomocą adresu URL. W takich przypadkach pominięcie izolacji nie jest tylko teoretyczne – oznacza to ponowne wyrenderowanie całej strony przy każdym otwarciu i zamknięciu modalu dla każdego użytkownika.

    Jeśli zespół już używa Radix lub Vaul do zapewnienia dostępności, należy najpierw przyjąć te rozwiązania, wprowadzając magazyn danych dopiero wtedy, gdy narzędzia analizy pokazują duży obciążenie na poziomie strony przy otwieraniu i zamykaniu modali, lub gdy produkt wymaga nawiniętych procesów działających poprawnie przy użyciu przycisku „Wstecz”. Przedwczesne wdrożenie takiej infrastruktury jest realnym problemem; podobnie jak konieczność ponownego wyrenderowania całej struktury przy każdym udostępnieniu, gdy panel sterowania jest obciążony.

    Co z tego wynika

    • Otwieranie lub zamykanie dowolnego modalu na dowolnej głębokości nigdy nie powoduje ponownego wyrenderowania całej strony – tylko warstwa modalu odczytuje jego stan.
  • Nestowanie ma charakter strukturalny: operacje push/pop na tablicy, a nie przypadek szczególny.
  • Każda treść modalu jest dzielona na fragmenty kodu i ładowana na żądanie.
  • Adres URL pozostaje przystępnym do udostępniania obrazem tego, co jest otwarte, włączając wiele warstw.
  • Żaden z tych elementów nie jest egzotyczny – zewnętrzne magazyny danych, portale, ładowanie opóźnione oraz synchronizacja za pomocą pushState to powszechnie używane narzędzia. Interesująca zasada dotyczy architektury: to, co decyduje o tym, co jest otwarte, nigdy nie może być przodkiem elementu, który nie powinien o tym wiedzieć.

    Pełna demonstracja Vite + React + TypeScript + Zustand z trzema nawarstwionymi modalami oraz licznikiem renderowania w czasie rzeczywistym znajduje się na GitHubie pod adresem react-modal-stack. Po wykonyaniu poleceń npm install && npm run dev przejdź do opcji Share → View comments → Reply i upewnij się, że strona w tle nigdy nie jest ponownie renderowana.

    Literatura pokrewna