Accueil / Articles / Gérer les modaux React sans re-renderiser la page : Stack Store et Portal Manager

Gérer les modaux React sans re-renderiser la page : Stack Store et Portal Manager

Conservez l’état modal dans une pile Zustand en dehors de l’arbre, affichez-le à l’aide d’un ModalManager et de ports frères, et synchronisez les dialogues imbriqués avec l’URL à l’aide de pushState.

2162 mots

En résumé — Évitez de placer l’état des modaux dans l’arbre des composants React. Stockez-le sous forme de pile dans un stock externe (Zustand par exemple), affichez-les à l’aide d’un ModalManager dédié qui est monté en tant que frère de l’application (et non en tant qu’ancêtre), et chargez le contenu des modaux via un portail utilisant lazy(). Ainsi, l’ouverture, la fermeture ou le nesting des dialogues ne provoquent jamais un re-render de la page en dessous ; le nesting se fait simplement par ajout à un tableau ; et l’URL reste synchronisée grâce à pushState / popstate.

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

Celui-ci fonctionne tant que tout se passe bien. Dès que l’état modal se trouve sur un ancêtre de la page, chaque action d’ouverture ou de fermeture provoque un re-render de toute cette sous-arborescence. Sur un écran léger, personne ne s’en aperçoit. Mais avec des graphiques, des listes virtualisées ou d’autres interfaces lourdes, cliquer sur « Partager » peut entraîner des ralentissements.

Ajoutez deux autres exigences : des dialogues qui ouvrent d’autres dialogues (partager → commentaires → répondre) et une URL qui reflète ce qui est affiché (mettre à jour, retourner en arrière, liens profonds) ; dans ce cas, un seul useState cesse d’être simplement gaspilleur et devient difficile à comprendre.

L’architecture présentée ci-dessous isole ces problématiques.

L’idée principale : retirer l’état des modaux de l’arbre de rendu

Le coût lié aux re-rendus dépend de l’endroit où se trouve l’état. Les flags des modaux gérés par un ancêtre obligent le sous-arbre correspondant à se mettre à jour à chaque modification.

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

Deux détails sont importants. Premièrement, l’état est un stack, et non une seule case — c’est ce qui permet le nesting gratuit. Deuxièmement, il s’agit d’un stock Zustand, et non de React Context. Context informe tous les consommateurs ; Zustand permet à un composant de sélectionner une partie spécifique de l’état afin que seul ce souscripteur se rérenderise. Pour une question transversale du type « qu’est-ce qui est ouvert ? », c’est cette différence qui est cruciale.

Ouvrir un modal ne devrait pas toucher directement l’état React

Les outils d’ouverture et de fermeture lisent et écrivent le stock via getState(), et non via 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);
}

Puisque openModal n’appelle jamais le hook, son invocation ne crée pas de sélection de store et ne réaffiche rien par elle-même — seule la mise à jour du store le fait, et seuls les composants qui ont choisi d’utiliser ce store en sont affectés. Appelez openModal(...) depuis n’importe quel gestionnaire de clic — y compris à l’intérieur d’un autre modal — et le seul réacteur est le composant chargé de réagir.

Le seul composant autorisé à s’en soucier

// 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 est monté une seule fois à côté de <App />, et non à l’intérieur :

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

Cette relation de frères et sœurs définit la structure. Si ModalManager enveloppait <App />, chaque changement de pile réafficherait le gestionnaire ainsi que ses enfants — y compris l’application. En tant que frères et sœurs sous une même racine, App n’entend jamais la mise à jour.

Les modaux imbriqués ne sont qu’un tableau plus long

Lorsque l’état est une pile, « modal à l’intérieur d’un modal » n’est pas un cas particulier : c’est le comportement par défaut de push.

Un dialogue de partage peut ouvrir une feuille de commentaires, qui à son tour peut ouvrir un dialogue de réponse ; chaque élément ajoute une nouvelle entrée :

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

Chaque couche possède sa propre depth (pour z-index) et se ferme grâce à sa propre id. La fermeture de la réponse laisse les commentaires et le partage intacts. Aucun composant modal récursif ni aucune machine à états personnalisée : simplement un tableau contenant trois éléments.

Rendre le shell simple et mémorisé

Le shell du modal — couche de superposition, carte, animation, gestion de la touche Escape — reste séparé du contenu et est enveloppé dans 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>
  );
});

Seule la couche la plus externe écoute la touche Escape ; sinon, une seule pression de touche tenterait de fermer toutes les couches. Comme la couche est indépendante du contenu, chaque élément s’charge avec lazy() pour chaque entrée du registre, afin qu’un dialogue de réponse rare ne gonfle pas le paquet initial :

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

Une version destinée à la production confirme que chaque élément modal devient un bloc distinct, chargé uniquement lorsque cette entrée s’ouvre.

Faire en sorte que l’URL dise la vérité

Aligner la barre d’adresses avec la pile représente un problème de synchronisation entre le stockage Zustand et window.location. Une erreur dans ce processus peut entraîner une boucle infinie ou faire en sorte que le bouton Retour quitte la page au lieu de fermer un dialogue.

Un référent booléen indique « ce changement provient de l’URL, ne le réécrivez pas » :

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

Préférez pushState à replaceState : chaque ouverture ajoute une véritable entrée dans l’historique, ce qui permet à la fonction « Retour » de fermer une couche à la fois — comme les utilisateurs s’y attendent avec trois dialogues imbriqués.

En d’autres termes : considérez l’orchestration des modaux comme une infrastructure, et non comme un état d’interface géré par l’écran. Les écrans gèrent eux-mêmes la récupération de données et l’état local des formulaires. L’infrastructure décide quels éléments superposés existent, dans quel ordre, et comment la barre d’adresse reflète cette pile. C’est cette séparation qui permet aux ressources coûteuses de rester inactives pendant que les dialogues s’ouvrent et se ferment.

Lorsqu’un imbriquement est nécessaire, préférez des appels explicites à openModal depuis le contenu plutôt que l’utilisation de flags booléens transmis via les parents. Les flags créent des liens de couplage : chaque parent doit être au courant de tous les dialogues enfants. Une clé de registre associée à des paramètres permet au parent d’ignorer les détails internes de l’enfant et laisse le gestionnaire contrôler l’ordre d’imbrication ainsi que les entrées de l’historique.

Comment cela s’intègre aux bibliothèques existantes

Cet état d’art ne remplace pas l’écosystème des modaux ; il résout simplement la question du placement de l’état. Chez les outils courants, on trouve des chevauchements :

  • Radix UI Dialog et Vaul se distinguent par leur accessibilité et leurs fonctionnalités gestuelles — pièges de focus, verrouillage du défilement, fermeture par glissement. Ils ne imposent pas d’endroit précis pour indiquer qu’un modal est ouvert, ce qui permet à l’un d’eux de servir de Shell. Les composants Store, Portal et Stack se situent en dessous d’eux, et non à leur place.
  • NiceModal (@ebay/nice-modal-react) permet de faire apparaître ou disparaître un modal de manière impérative (NiceModal.show(MyModal)) sans utiliser de fonctions booléennes. Il convient lorsque la synchronisation via l’URL et le nesting profond ne sont pas nécessaires. La conception basée sur Stack et Store est plus proche de l’ergonomie de NiceModal, en plus d’intégrer réellement un mécanisme de gestion des historiques.
  • Next.js intercepting / parallel routes résout le problème de synchronisation des URL au niveau du framework : un modal représente un segment de route par rapport à la page. Avec App Router, cette approche est souvent plus naturelle que l’utilisation manuelle de pushState. La méthode basée sur des stores repose sur la même idée pour les applications React Vite/CRA ou anciennes Pages Router qui ne disposent pas de routes d’interception natives.
  • Si une bibliothèque gère déjà Chrome, conservez ses primitives en tant que structure de base et utilisez les stores ainsi que ModalManager pour assurer l’isolation et le nesting des composants.

    Est-ce vraiment nécessaire ? L’utilisation de useState au niveau le plus élevé dans App nécessite moins de lignes de code et convient à de nombreux projets. Soyez précis quant aux avantages apportés par la stack externe — et expliquez pourquoi « moins de re-renders » n’est pas une promesse marketing vague.

    Le coût de réconciliation augmente en fonction du sous-arbre, et non de l’ampleur de la modification d’état. Lorsqu’un composant est réaffiché, React parcourt les descendants non mémorisés même si la différence dans le DOM est minime. Changer la valeur de modalOpen dans App n’est pas simplement « afficher une boîte de dialogue » ; cela fait redémarrer toutes les fonctions entre App et les éléments les plus bas, recalcule les dérivées, vérifie à nouveau les utilisations de useMemo, et déclenche à nouveau les effets dont les dépendances ont changé. Sur une page légère, cela reste imperceptible. Mais avec un tableau, un graphique, un éditeur avancé ou une longue liste virtuelle, cela se traduit par un délai entre une apparition instantanée et des ralentissements d’un ou deux frames.

    C’est la localisation qui détermine le rayon d’impact, et non la taille du chargement. Un booléen et une pile de cinq éléments consomment une quantité de mémoire similaire ; ce qui compte, c’est qui est notifié. En déplaçant l’état des modaux dans un stock externe que seul ModalManager lit, on réduit l’impact d’un modal ouvert de toute la page à un composant dédié. Rien d’autre ne peut s’en apercevoir à moins qu’il n’ait sélectionné cette partie spécifique.

    Le contexte n’est pas la solution, même s’il en a l’air. Le contexte élimine le problème du « drilling de props » ; il ne stoppe pas les re-renderings. Chaque consommateur de useContext est mis à jour lorsque la valeur change, même s’ils ignorent le champ qui a changé. Un <ModalProvider> racine recrée le même rayon d’impact initial avec une API différente. Les stocks basés sur des sélecteurs (Zustand, Jotai, Redux selectors) résolvent ce problème en permettant aux composants de surveiller une partie spécifique.

    Les modaux s’ouvrent au pire moment pour payer cette taxe. Les feuilles de partage, les threads de commentaires et les confirmations apparaissent après un simple clic et devraient se faire instantanément. Un retard dans l’affichage, qui serait invisible avec un compteur en arrière-plan, devient évident lorsqu’il s’agit de la réponse directe à un clic.

    Vous pouvez mesurer plutôt que de vous fier aux affirmations. Une démonstration peut afficher un compteur d’affichage sur la page et sur chaque modal : ouverture, imbriquement, fermeture, tout en observant le compteur de la page rester stable tandis que chaque modal augmente indépendamment. Le Profiler des outils de développement React montre le même résultat : les opérations s’effectuent à l’intérieur du portail, et non à l’intérieur de l’arborescence des applications sœurs.

    Tous les dialogues n’ont pas besoin de cette structure précise. Une confirmation statique sans encadrement sur une page simple ne justifie pas l’utilisation d’une telle structure. Cela devient important lorsque la page située en dessous du modal est coûteuse à re-render, lorsqu’il y a réellement des niveaux d’encadrement, ou lorsque l’état ouvert doit être conservé après un rechargement via l’URL. Dans ces cas, omettre l’isolation n’est pas théorique : cela entraîne un re-render de toute la page à chaque ouverture et fermeture pour tous les utilisateurs.

    Si une équipe a déjà standardisé sur Radix ou Vaul pour l’accessibilité, qu’elle adopte d’abord ces solutions et n’introduise le store que lorsque les outils d’analyse montrent des charges importantes au niveau de la page lors des ouvertures/fermetures, ou lorsque le produit nécessite des flux imbriqués avec une fidélité au bouton Retour. Une infrastructure mise en place prématurément est réelle ; tout comme le fait de devoir re-render toute la structure à chaque partage une fois que le tableau de bord est chargé.

    Avantages de cette approche

    • Ouvrir ou fermer un modal à n’importe quel niveau ne re-render jamais la page — seule la couche du modal lit son état.
  • Le nesting est structurel : il s’agit d’opérations push/pop sur un tableau, et non d’un cas particulier.
  • Chaque corps de modal est divisé en parties et chargé sur demande.
  • L’URL reste une représentation partageable et compatible avec le bouton Retour, montrant ce qui est ouvert, y compris les multiples couches.
  • Aucun de ces éléments n’est exotique : un stockage externe, un portail, le chargement différé et la synchronisation via pushState sont des outils courants. La règle intéressante est architecturale : ce qui décide de ce qui est ouvert ne doit en aucun cas être un ancêtre de ce qui ne devrait pas s’en soucier.

    Une démonstration complète de Vite + React + TypeScript + Zustand avec trois modals imbriqués et un compteur de rendu en temps réel se trouve sur GitHub : react-modal-stack. Après avoir exécuté npm install && npm run dev, allez sur Partager → Afficher les commentaires → Répondre pour constater que la page en arrière-plan ne se rerend jamais.