Modales en React sin volver a renderizar la página: Stack Store y Portal Manager
Mantenga el estado modal en una pila Zustand fuera del árbol, renderícelo con un ModalManager y portales hermanos, y sincronice los diálogos anidados con la URL mediante pushState.
TL;DR — Mantenga el estado de los modales fuera del árbol de componentes de React. Almacénelo como una pila en un almacén externo (Zustand, por ejemplo), renderícelo a través de un
ModalManagerdedicado que se monte como hermano de la aplicación (no como ancestro), y cargue los cuerpos de los modales mediante un portal conlazy(). De esta manera, al abrir, cerrar o anidar diálogos nunca se vuelve a renderizar la página subyacente; el anidamiento consiste simplemente en añadir elementos a un array; y la URL se mantiene actualizada mediantepushState/popstate.
Un sistema de modales básico común en React se ve así:
function App() {
const [modal, setModal] = useState(null);
// …rest of the app tree lives here
}
Funciona hasta que deja de hacerlo. Una vez que el estado modal se encuentra en un ancestro de la página, cada acción de apertura o cierre vuelve a renderizar todo ese subárbol. En pantallas simples nadie se da cuenta, pero en gráficos, listas virtualizadas u otras interfaces complejas, tocar “Compartir” puede causar retrasos.
Añada dos requisitos más: diálogos que abren otros diálogos (compartir → comentarios → responder) y una URL que refleje lo que está abierto (actualizar, volver atrás, enlaces profundos); además, si se utiliza un único useState, deja de ser simplemente una pérdida de recursos y se vuelve difícil de comprender.
La arquitectura que se muestra a continuación isola esos problemas.
La idea central: sacar el estado modal del árbol de renderizado
El costo por volver a renderizar se debe a dónde reside el estado. Las marcas de modalidad controladas por un ancestro obligan al subárbol de ese ancestro a actualizarse con cada cambio.
// 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 }),
}));
Hay dos detalles importantes. Primero, el estado es un stack, no una sola ranura; eso es lo que hace que el anidamiento sea posible sin costos. Segundo, se trata de un almacén Zustand, no de React Context. Context notifica a todos los consumidores; Zustand permite que un componente seleccione una parte específica del estado para que solo ese suscriptor se actualice. En el caso de una preocupación transversal como “¿qué está abierto?”, esa diferencia es clave.
Al abrir un modal no se debe modificar directamente el estado de React
Los auxiliares para abrir y cerrar leen y escriben en el almacén a través de getState(), y no mediante 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);
}
Dado que openModal nunca llama al hook, invocarlo no crea una selección de store ni vuelve a renderizar nada por sí mismo; solo la actualización del store lo hace, y solo los componentes que han optado por usar ese store la perciben. Llama a openModal(...) desde cualquier manejador de clic, incluso desde dentro de otro modal, y el único componente que reacciona es aquel cuya función es hacerlo.
El único componente que puede preocuparse
// 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 se monta una sola vez junto a <App />, no dentro de él:
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ModalManager />
<ModalUrlSync />
</StrictMode>,
);
Esa relación de hermanamiento determina el diseño. Si ModalManager envolviera a <App />, cada cambio en la pila de componentes volvería a renderizar al manager y luego a sus hijos, incluida la aplicación. Al ser hermanos bajo una misma raíz, App nunca recibe notificación de las actualizaciones.
Los modales anidados son simplemente un array más largo
Dado que el estado es un stack, “modal dentro de modal” no constituye un caso especial: es el comportamiento por defecto de push.
Un diálogo de compartir puede abrir una hoja de comentarios, la cual a su vez puede abrir un diálogo de respuesta; cada uno agrega otra entrada:
// Inside ShareModal
<button onClick={() => openModal('comments', { productId })}>
View comments
</button>
// Inside CommentSheet
<button onClick={() => openModal('reply', { commentId })}>
Reply
</button>
Cada capa tiene su propio depth (para z-index) y se cierra mediante su propio id. Al cerrar la respuesta, los comentarios y el compartido permanecen intactos. No hay componentes modales recursivos ni máquinas de estado personalizadas: solo un array con tres elementos.
Mantener la estructura básica simple y memorizada
La estructura básica del modal —la superposición, la tarjeta, la animación y el manejo de la tecla Escape— permanece separada del contenido y se envuelve con 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>
);
});
Solo la capa más externa escucha la tecla Escape; de lo contrario, una sola presión de tecla intentaría cerrar todas las capas. Dado que la capa es independiente del contenido, cada sección se carga con lazy() por entrada de registro, de modo que un diálogo de “respuesta” poco frecuente no haga crecer excesivamente el paquete inicial:
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', ... },
};
Una versión para producción confirma que cada modal se convierte en un bloque separado, el cual solo se carga cuando se abre esa entrada.
Hacer que la URL diga la verdad
Mantener la barra de direcciones alineada con la pila es un problema de sincronización entre el almacén Zustand y window.location. Si se comete un error, el ciclo continúa indefinidamente o la opción Atrás hace que se salga de la página en lugar de cerrar un diálogo.
Un ref booleano indica “este cambio provino de la URL, no lo escriba de vuelta”:
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]);
}
Preferir pushState en lugar de replaceState: cada nueva ventana abierta añade un registro real de la historia, por lo que el botón Atrás cierra una capa a la vez, tal como esperan los usuarios de tres diálogos anidados.
Dicho de otra manera: trate la orquestación de modales como infraestructura, y no como estado de interfaz controlado por la pantalla. Las pantallas se encargan de obtener sus propios datos y gestionar el estado local de los formularios. La infraestructura decide qué superposiciones existen, en qué orden y cómo la barra de direcciones refleja esa estructura. Esa separación permite que los componentes costosos permanezcan inactivos mientras los diálogos se abren y cierran.
Cuando sea necesario anidar diálogos, prefiera llamadas explícitas a openModal desde el contenido en sí, en lugar de usar flags booleanos que se transmitan a través de los elementos padre. Los flags generan acoplamiento: cada elemento padre debe estar al tanto de todos los diálogos hijos. Un clave de registro junto con parámetros permite que el elemento padre ignore los detalles internos del hijo y deja al gestor a cargo del orden de apilamiento y de los registros de la historia.
Cómo se integra con las bibliotecas existentes
Este patrón no reemplaza el ecosistema de modales; soluciona el problema de dónde se debe almacenar el estado. Presenta superposiciones con herramientas comunes:
- Radix UI
Dialogy Vaul destacan en accesibilidad y gestos: trampas de enfoque, bloqueo de desplazamiento, cierre al arrastrar. No imponen una ubicación específica para indicar qué elementos están abiertos, por lo que cualquiera de ellos puede funcionar comoShellen este caso. Store, portal y stack se encuentran debajo de ellos, no en su lugar. - NiceModal (
@ebay/nice-modal-react) permite mostrar/ocultar elementos de forma imperativa (NiceModal.show(MyModal)) sin utilizar valores booleanos. Es adecuado cuando no se requiere sincronización mediante URL ni anidamiento profundo. El diseño basado en stack y store se acerca más a la ergonomía de NiceModal además de contar con una verdadera lógica de historial.
pushState de forma manual. El enfoque con store sigue la misma lógica para aplicaciones Vite/CRA React o versiones anteriores de Pages Router que no cuentan con rutas de intercepción nativas.Si una biblioteca ya maneja Chrome, mantén sus primitivas como estructura base y conserva el store junto con ModalManager para lograr aislamiento y anidamiento.
¿Es necesario todo esto? El uso de useState a nivel superior en App implica menos líneas de código y es suficiente para muchos productos. Sé específico sobre qué aporta la stack externa, y explica por qué “menos actualizaciones de renderizado” no es un término publicitario vago.
El costo de reconciliación aumenta con el subárbol, no con cuán pequeño fue el cambio de estado. Cuando un componente se vuelve a renderizar, React procesa los descendientes que no están memorizados, incluso si la diferencia en el DOM es mínima. Cambiar el valor de modalOpen en App no significa “solo mostrar un diálogo”; implica volver a ejecutar todas las funciones desde App hasta los nodos más bajos, volver a calcular las derivadas, revisar nuevamente los valores de useMemo, y reiniciar los efectos cuyas dependencias han cambiado. En una página ligera, esto es imperceptible. Pero en una tabla, gráfico, editor avanzado o lista virtualizada larga, se manifiesta como la diferencia entre una aparición instantánea y una demora de uno o dos fotogramas.
La ubicación determina el radio de impacto, no el tamaño de la carga útil. Un valor booleano y una pila de cinco elementos ocupan memoria similar; lo que importa es quién recibe la notificación. Al trasladar el estado del modal a un almacenamiento externo que solo lee ModalManager, “un modal abierto” pasa de afectar toda la página a estar limitado a un componente dedicado. Nada más puede darse cuenta a menos que haya seleccionado esa sección específica.
El contexto no es la solución, aunque parezca serlo. El contexto elimina el problema de la propagación de propiedades, pero no evita las actualizaciones repetidas. Cada consumidor de useContext se actualiza cuando cambia el valor, incluso si ignoran el campo que ha cambiado. Un <ModalProvider> raíz recrea el mismo radio de impacto original mediante una API diferente. Los almacenes basados en selectores (Zustand, Jotai, selectores de Redux) lo resuelven al permitir que los componentes monitoren una sección específica.
Los modales aparecen en el peor momento para pagar ese impuesto. Las hojas de compartir, los hilos de comentarios y las confirmaciones deben generarse al tocar la pantalla y deberían ser instantáneos. Un frame perdido que se ocultaría con un temporizador de fondo resulta evidente cuando es la respuesta directa a un clic.
Puedes medir en lugar de confiar en las afirmaciones. Una demostración puede incluir un contador de renderizado en la página y en cada modal: al abrirlos, anidarlos o cerrarlos, el contador de la página permanece estable mientras cada modal incrementa por separado. El Profiler de React DevTools muestra lo mismo: el trabajo se realiza dentro del portal, no dentro del árbol de aplicaciones hermanas.
No todo diálogo necesita esta forma exacta. Una confirmación estática sin anidamiento en una página sencilla no justifica el uso de dicha estructura. Es importante cuando la página que se muestra debajo del modal es costosa de volver a renderizar, cuando existe verdadero anidamiento o cuando el estado abierto debe mantenerse tras una actualización por URL. En esos casos, omitir la aislación no es algo teórico: implica una vuelta a renderizar de toda la página cada vez que se abre o cierra el modal, para todos los usuarios.
Si un equipo ya ha estandarizado en Radix o Vaul para la accesibilidad, adopte primero esos componentes y solo introduzca el sistema de almacenamiento cuando los analizadores muestren trabajo a nivel de página al abrir o cerrar el modal, o cuando el producto requiera flujos anidados con funcionalidad similar al botón Atrás. Una infraestructura prematura es real; lo mismo ocurre con tener que realizar una renderización completa de todo el árbol cada vez que se hace clic en Compartir, una vez que el panel de control está ocupado.
Qué obtiene con todo esto
- Al abrir o cerrar cualquier modal, sin importar su profundidad, la página nunca se vuelve a renderizar; solo la capa del modal lee su estado.
Ninguno de estos elementos es exótico: un almacén externo, un portal, la carga diferida y la sincronización con pushState son herramientas comunes. La regla interesante es arquitectónica: lo que decida qué está abierto nunca debe ser un ancestro de aquello que no debería preocuparse por ello.
Existe una demostración completa de Vite + React + TypeScript + Zustand con tres modales anidados y un contador de renderizado en tiempo real en GitHub: react-modal-stack. Después de ejecutar npm install && npm run dev, ve a Compartir → Ver comentarios → Responder y verifica que la página de fondo nunca se vuelva a renderizar.