Esta es la lista de productos del panel de una tienda, con un botón para borrar cada uno, escrita como la enseñan la documentación de las librerías de componentes y casi cualquier tutorial:
// src/modules/product/components/ProductList/ProductList.tsx
export default function ProductList() {
const { products, refetch } = useProducts();
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [selected, setSelected] = useState<Product | null>(null);
const handleDelete = (product: Product) => {
setSelected(product);
setIsDeleteOpen(true);
};
return (
<>
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name}
<button onClick={() => handleDelete(product)}>Delete</button>
</li>
))}
</ul>
<DeleteProductDialog
open={isDeleteOpen}
product={selected}
onOpenChange={setIsDeleteOpen}
onDeleted={refetch}
/>
</>
);
}
El componente es correcto: hace lo que promete, se lee de una pasada y una pantalla con un único modal de confirmación puede vivir así años sin un solo incidente. Lo que interesa medir no es su comportamiento, sino dónde vive la respuesta a una pregunta concreta: ¿qué modal está abierto ahora mismo?.
Cinco puntos, y conviene clasificarlos. El booleano vive en quien tiene el botón, así que la visibilidad del modal es un detalle de implementación de la lista. Los datos del modal son un segundo estado, selected, que se actualiza en otra línea y que el tipo permite que valga null con el modal abierto. El modal se monta al lado del botón, dentro de la lista, aunque no forme parte de ella. El refetch y el cierre viajan por props desde quien abre hasta quien confirma. Los cuatro son molestias locales: incómodas, pero reversibles con un refactor de una tarde.
El quinto es de otra naturaleza: ningún sitio de la aplicación sabe qué modal está abierto. Cada componente con un botón guarda su trozo de la respuesta, y la pantalla sólo puede enseñar una cosa a la vez.
Nada de esto tiene coste observable mientras haya una pantalla, uno o dos modales y nada que los abra desde fuera de ella. Se vuelve medible con el tercer modal, el segundo sitio que abre el mismo, o el primer modal que abre otro.
Adónde va esto
El destino, en bruto y sin justificar todavía. Abrir un modal es poner un valor en el único hueco que hay:
// src/modules/product/components/ProductList/ProductList.tsx
<button
onClick={() => handleOpenModal(new DeleteProductModalProps(product, refetch))}
>
Delete
</button>
Un único componente, montado una vez en la aplicación, decide qué modal pinta según la clase del valor:
// src/app/Modals.tsx
export default function Modals() {
const { open } = useModal();
if (open instanceof DeleteProductModalProps) {
return <DeleteProduct product={open.product} refetch={open.refetch} />;
}
if (open instanceof EditProductModalProps) {
return (
<EditProduct
product={open.product}
refetch={open.refetch}
draft={open.draft}
/>
);
}
if (open instanceof CreateCategoryModalProps) {
return <CreateCategory prev={open.prev} onCreate={open.onCreate} />;
}
return null;
}
Y el estado de todos los modales de la aplicación es esto:
// src/modules/modal/context/ModalProvider.tsx
const [open, setOpen] = useState<ModalProps | null>(null);
Lo que conviene mirar son el draft de Editar producto y el prev de Nueva categoría: permiten que un modal abra otro y vuelva sin perder lo escrito, que es la parte difícil.
Dos costes
1. Los estados imposibles son representables
Con un modal de borrado y otro de edición, la lista tiene dos booleanos y dos selected. Dos booleanos son cuatro combinaciones, y la pantalla sólo puede enseñar tres: ninguno, el de borrar o el de editar. Con N modales son 2^N combinaciones para N+1 pantallas posibles, más todas aquellas en las que el modal está abierto y su selected es null.
Nada de eso falla hoy, porque cada handle* pone las dos variables en orden. Lo que falla es el código que se escribe para defenderse: cada modal nuevo trae su if (!product) return null dentro, una guarda que protege de un estado que el tipo nunca debió permitir. La documentación de React lo nombra como principio, "Avoid contradictions in state", y lo resuelve con un ejemplo de formulario que cambia isSending e isSent por un único status.
2. El modal vive donde está el botón
El día que Editar producto tiene que abrirse también desde el toast de "Producto creado", el estado sube hasta el ancestro común de la lista y el toast. Con un tercer sitio —un atajo de teclado, el buscador— sube otra vez. El efecto de segundo orden es que cada modal que se abre desde varios sitios acaba con su contexto o su store propio, y una aplicación con quince modales tiene quince implementaciones de lo mismo: un hueco que guarda qué está abierto y con qué datos.
Tres componentes guardan su trozo de la respuesta, y dos de ellos montan el mismo modal.
Qué hay ya resuelto, y qué conviene descartar
Las primitivas controladas —Radix, Headless UI, el <dialog> del navegador— resuelven lo difícil del DOM: foco, fondo inerte, Escape, lectores de pantalla. Dónde vive su open lo dejan a quien las usa, y Headless UI lo dice sin rodeos: "Dialogs are controlled components, meaning that you have to provide and manage the open state yourself".
La defensa explícita de dejarlo local tiene autor. Kent C. Dodds clasifica "modal isOpen state" como estado de interfaz y, sobre llevarlo a un store global, escribe: "this is fine for state that is truly global, but for simple state (like whether a modal is open or form input value state) this is a big problem." Hay que concedérselo entero para un modal que sólo abre un botón: subirlo es pagar indirección sin cobrar nada. El argumento deja de sostenerse con el segundo sitio que abre el mismo modal, que es cuando qué está abierto deja de ser estado de un componente y pasa a ser estado de la pantalla.
El modalType + modalProps en un store es la respuesta canónica desde 2016, cuando Dan Abramov la publicó en Stack Overflow: una acción describe qué modal se quiere ver, el reducer guarda { modalType, modalProps } y un <ModalRoot> busca el componente en un mapa. Es un slot único y resuelve los dos costes. Lo que trae es una clave de texto y un modalProps sin tipo: el compilador no relaciona 'DELETE_POST' con lo que ese modal necesita.
Los gestores globales por componente, como @ebay/nice-modal-react, añaden una API de promesa (await NiceModal.show(...)). Su store es un objeto por id con varios modales visibles a la vez, el tipado de las props es parcial y no publica versión desde octubre de 2023.
El modal en la URL —las intercepting routes de Next.js, el backgroundLocation de React Router— hace lo abierto compartible y resistente al refresco y al botón de atrás, a costa de una ruta por modal y de que todo lo que necesita quepa en la URL.
| Familia | Coste 1: estados imposibles | Coste 2: el modal vive donde está el botón | Un modal que abre otro |
|---|---|---|---|
| Primitivas controladas | no lo aborda | no lo aborda | el padre sigue montado |
modalType + modalProps
|
resuelto | resuelto | sustituye al padre: se pierde |
| nice-modal-react | parcial: varios visibles a la vez | resuelto | el padre sigue montado |
| Modal en la URL | resuelto | resuelto | una ruta anidada por nivel |
Nota: la tabla mide estos dos costes y el anidamiento, no la accesibilidad, en la que las primitivas ganan con diferencia. Verificado en octubre de 2026 contra React 19.3.0,
@ebay/nice-modal-react1.2.13, Headless UI 2.2.10, Next.js 16.4.0 y React Router 8.4.0.
Debajo de la respuesta de Abramov está la conversación que este artículo continúa. Un comentario pide convertir el estado en una lista para poder "open a modal from a modal" y volver atrás. Abramov contesta que ha trabajado con diseñadores que querían modales exclusivos, "so the approach I wrote up solves accidental nesting". Y un año después otro lector plantea el caso que ninguna de las dos posturas resuelve: un usuario que ha editado unos campos, necesita un segundo modal y "will loose the changes he maid". Nadie le responde.
La tesis
La documentación de React tiene un ejemplo que hace este argumento sin nombrar los modales. Un acordeón con dos paneles cambia dos booleanos isActive por un índice en el padre, y lo justifica así: "Using the active index instead of two “is shown” flags ensured that only one panel is active at a given time."
Un modal abierto es ese índice a escala de aplicación:
Qué modal está abierto es un único dato. Se modela como un solo hueco que contiene un valor —una instancia que describe el modal y trae todo lo que necesita para pintarse—, no como un booleano por componente ni como una pila.
Eso determina cuatro cosas al escribir código: una clase por modal, un único useState en un provider, un único componente que monta los modales, y un shell que no sabe si está abierto porque sólo existe mientras lo está.
Los tres sitios escriben en el mismo hueco, y sólo un componente monta modales.
La implementación, pieza por pieza
La clase base
El archivo completo:
// src/modules/modal/domain/modal-props.ts
/**
* Every modal of the app is a subclass. A class and not an interface on purpose:
* `Modals` narrows with `instanceof`, and interfaces are erased at compile time.
*/
export abstract class ModalProps {}
Está vacía y es la pieza que hace funcionar el resto. Da al hueco un tipo común y, por ser una clase, existe en tiempo de ejecución: open instanceof EditProductModalProps estrecha el tipo de open sin switch, sin as y sin escribir aparte una unión discriminada con un campo type: string que hay que mantener sincronizada con los componentes.
Una clase por modal
Todo lo que el modal necesita entra por el constructor, y quien lo abre pasa su refetch:
// src/modules/product/domain/modal/product-modal.ts
export class DeleteProductModalProps extends ModalProps {
constructor(
readonly product: Product,
readonly refetch: () => void,
) {
super();
}
}
Con esto el estado imposible del gancho desaparece por construcción: no hay forma de abrir el modal de borrado sin un producto, porque no hay forma de construir un DeleteProductModalProps sin él.
El provider y el hook
El provider es un useState:
// src/modules/modal/context/ModalProvider.tsx
export default function ModalProvider({
children,
}: {
children: React.ReactNode;
}) {
const [open, setOpen] = useState<ModalProps | null>(null);
// the slot holds class instances, never functions: `setOpen` would run a function as an updater
const value = useMemo<ModalContextValue>(
() => ({ open, handleOpenModal: setOpen }),
[open],
);
return (
<ModalContext.Provider value={value}>{children}</ModalContext.Provider>
);
}
El comentario no es decorativo. useState guarda una instancia tal cual, pero si recibe una función la ejecuta como updater y guarda lo que devuelva. Por eso lo que viaja en el hueco son objetos, y las funciones que un modal necesite —el refetch— van dentro de ellos.
El hook añade el cierre, que es poner null:
// src/modules/modal/hooks/useModal.ts
export default function useModal() {
const { open, handleOpenModal } = useContext(ModalContext);
const handleCloseModal = useCallback(
() => handleOpenModal(null),
[handleOpenModal],
);
return { open, handleOpenModal, handleCloseModal };
}
Abrir un modal con otro abierto lo sustituye. No hay que decidir qué hacer con el anterior porque no hay sitio para él.
Modals, montado una vez
Modals, el de la sección Adónde va esto, se monta una sola vez, dentro del provider y fuera de cualquier pantalla; nunca al lado de un botón ni dentro de una fila:
// src/app/App.tsx
export default function App() {
return (
<ModalProvider>
<main>
<h1>Products</h1>
<ProductList />
</main>
<Modals />
</ModalProvider>
);
}
El coste está aquí. Cada modal es una clase y una rama instanceof escrita a mano, y el compilador no avisa si una clase no tiene rama: el modal simplemente no se pinta. A cambio, Modals es el único sitio que conoce los componentes de los modales, y quien abre sólo conoce la clase. Cuando las ramas crecen, se pueden repartir en un componente por feature que Modals monta uno detrás de otro; es opcional y no cambia nada de lo demás. En el proyecto del que sale este patrón son 21 clases y 21 ramas repartidas así en tres componentes.
El shell: su ciclo de vida es la apertura
El shell nunca recibe un open. Lo monta Modals cuando el hueco tiene su clase y lo desmonta cuando deja de tenerla, así que el estado del formulario nace al abrir y se descarta al cerrar sin un solo useEffect de limpieza. Se apoya en <dialog> y showModal(), que dan el top layer, el resto de la página inerte, el foco inicial y aria-modal implícito. Versión reducida, sin estilos ni el cierre al pulsar el fondo:
// src/modules/modal/components/Modal/Modal.tsx
export default function Modal({
labelledBy,
children,
onClose,
loading = false,
}: Props) {
const ref = useRef<HTMLDialogElement>(null);
const { handleCloseModal: handleCloseSlot } = useModal();
const handleClose = () => {
if (loading) return;
if (onClose) onClose();
else handleCloseSlot();
};
useEffect(() => {
// the browser doesn't give the focus back when React removes the dialog: keep the opener
const opener = document.activeElement as HTMLElement | null;
ref.current?.showModal();
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
if (opener?.isConnected) opener.focus();
};
}, []);
// Escape fires `cancel` and the browser would close the <dialog> on its own, leaving the
// slot full: the close goes through the slot instead
const handleCancel = (e: React.SyntheticEvent<HTMLDialogElement>) => {
e.preventDefault();
handleClose();
};
return (
<dialog ref={ref} aria-labelledby={labelledBy} onCancel={handleCancel}>
{children}
</dialog>
);
}
La accesibilidad cuesta dos piezas que el navegador no pone, y las dos están medidas. La primera: si no se intercepta cancel, Escape cierra el <dialog> nativo mientras el hueco sigue lleno; el componente queda montado, invisible y con el estado de React diciendo que está abierto. La segunda: al desmontar el diálogo el foco cae en <body>, no en el botón que lo abrió, que es lo que pide el patrón de WAI-ARIA para diálogos modales. De ahí el opener guardado al montar. Con un <div> como contenedor, que es lo habitual, a esas dos se suman la trampa de foco, el fondo inerte y los atributos ARIA, todo escrito a mano.
El modal y su hook
El componente elige el shell, pone los textos y delega en su hook, que tiene el estado y el envío:
// src/modules/product/components/DeleteProduct/DeleteProduct.tsx
export default function DeleteProduct({ product, refetch }: Props) {
const { handleCloseModal } = useModal();
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
setLoading(true);
await deleteProduct(product.id);
setLoading(false);
refetch();
handleCloseModal();
};
return (
<FormModal
title="Delete product"
nextText="Delete"
onSubmit={handleSubmit}
loading={loading}
>
<p>“{product.name}” will be deleted. This can't be undone.</p>
</FormModal>
);
}
Primero se refresca la lista que abrió el modal y después se cierra. Mientras loading es true, el shell ignora Escape y el fondo: una operación a medias no se cancela por un gesto accidental.
Vuelta al inventario
El booleano que vivía en quien tenía el botón no existe: abrir es construir un valor. El segundo estado con los datos tampoco: los datos están dentro del valor y no pueden faltar. El modal ya no se monta al lado del botón sino una vez, en Modals. El refetch sigue viajando, pero dentro de la instancia y no por props de componente en componente. Y la pregunta de qué modal está abierto tiene ahora una respuesta en un único sitio, con un tipo que no admite dos.
La prueba de fuego: el modal que abre otro modal
Editar producto tiene el nombre cambiado y sin guardar, y la categoría que necesita todavía no existe. El usuario pulsa Nueva categoría, que es un modal con su propio formulario. Al crearla tiene que volver a Editar producto con el nombre tal como lo dejó y la categoría nueva ya elegida; al cancelar, con el nombre y nada más. Es el caso del comentario sin respuesta bajo la respuesta de Abramov, y es el que rompe el slot único tal como está hasta ahora: abrir el hijo sustituye al padre, el padre se desmonta y su useState desaparece.
La salida obvia es una pila: un ModalProps[] que pinta todas sus entradas, con el padre montado debajo del hijo y su borrador intacto. Con <dialog> funciona, porque el de abajo queda inerte. Pero deja estados vivos en un arreglo: todo lo que hay debajo sigue montado, con sus efectos y sus peticiones, y la implementación tiene que decidir cuál está arriba, a cuál le llega Escape y qué aparece al cerrar. El slot único se queda con un solo modal vivo y una sola regla.
La solución con el slot único es que el hijo reciba a dónde volver. El padre, antes de desmontarse, mete lo que tiene que sobrevivir en una instancia nueva de sí mismo:
// src/modules/product/components/EditProduct/hooks/useEditProduct.ts
// the parent is about to be unmounted: whatever must survive goes into a new instance of itself
const handleNewCategory = () => {
const current: ProductDraft = { name, price, categoryId };
handleOpenModal(
new CreateCategoryModalProps(
new EditProductModalProps(product, refetch, current),
(category: Category) =>
new EditProductModalProps(product, refetch, {
...current,
categoryId: category.id,
}),
),
);
};
Y el padre, al montarse, empieza desde ese borrador si lo trae:
// src/modules/product/components/EditProduct/hooks/useEditProduct.ts
const [name, setName] = useState(draft?.name ?? product.name);
const [price, setPrice] = useState(draft?.price ?? product.price);
const [categoryId, setCategoryId] = useState(
draft?.categoryId ?? product.categoryId,
);
El hijo no sabe quién lo abrió. Recibe prev, a dónde vuelve Cancelar, y onCreate, que construye a dónde vuelve Crear con la categoría nueva ya elegida. Sin los imports:
// src/modules/product/components/CreateCategory/CreateCategory.tsx
export default function CreateCategory({ prev, onCreate }: Props) {
const { handleOpenModal } = useModal();
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
setLoading(true);
const category = await createCategory(name);
setLoading(false);
handleOpenModal(onCreate(category));
};
return (
<FormModal
title="New category"
nextText="Create"
onSubmit={handleSubmit}
onClose={() => handleOpenModal(prev)}
loading={loading}
>
<label>
Category name
<input value={name} onChange={(e) => setName(e.target.value)} />
</label>
</FormModal>
);
}
prev es un memento: una foto del padre tomada justo antes de soltarlo. Volver es poner esa foto en el hueco. Por eso el mismo CreateCategory sirve para un futuro Crear producto sin tocar una línea: quien lo abre decide a dónde se vuelve.
El coste se dice aquí mismo: sólo vuelve lo que se mete en la instancia. El foco dentro del padre, su scroll, un input no controlado o una petición en curso no viajan en prev, y el padre vuelve sin ellos. En la práctica obliga a que el estado que importa sea controlado y quepa en un objeto, que es una restricción sana para un formulario y una mala noticia para un editor con historial de deshacer.
En la pila el borrador sigue vivo debajo; con el slot viaja dentro del valor que dice a dónde volver.
Lo que no cambió para resolver el anidamiento: el provider, el hook, el shell y Modals. Ninguno sabe que un modal puede abrir otro, porque para ellos abrir y volver son la misma operación con distinto valor.
Matices y para quién es
No hay animación de salida. El shell se desmonta en el mismo render en que el hueco pasa a null, así que no queda nada en pantalla que animar. Las librerías que la tienen guardan el último modal precisamente por eso; el código de @mantine/modals lo explica en un comentario: "Keeping the last one is necessary for providing a clean exit transition." Con el slot único hay que hacer lo mismo a propósito: retrasar el null hasta que termine la transición.
Un modal sólo existe mientras está abierto, y su estado también. Es lo que hace que no haya que limpiar nada al cerrar, y es también lo que impide recordar un borrador entre aperturas: si Editar producto debe recuperar lo escrito tras cerrarse por error, ese borrador tiene que vivir fuera del modal.
Para quién no es, primero: una pantalla con un único modal de confirmación que sólo abre un botón. Ahí esto son una clase, un provider y un componente Modals para sustituir un useState, y la respuesta correcta es la de Kent C. Dodds: el open local de la primitiva o del <dialog>. El balance se invierte con dos o más de estas condiciones: tres o más modales, el mismo modal abierto desde más de un sitio, o modales que abren modales.
La regla práctica cabe en una línea: si dos modales no pueden verse a la vez, no les des dos estados.
El código
El proyecto ejecutable está en hgomezrobaina/react-modal-slot-example: el panel de productos completo, con las piezas de este artículo y el shell sobre <dialog>, en React 19 y Vite. Se levanta con npm install && npm run dev.
Lo que conviene mirar no es que arranque, sino su suite: diez tests que corren en jsdom en alrededor de un segundo, sin navegador (npm test). Ahí está comprobado lo que este artículo afirma: que abrir un modal con otro abierto lo sustituye y nunca deja dos diálogos, que Escape cierra a través del hueco y no sólo el <dialog> nativo, que el foco vuelve al botón que abrió el modal, que el padre se desmonta mientras el hijo está abierto, y que Nueva categoría devuelve a Editar producto con el nombre sin guardar y la categoría nueva ya elegida, o sólo con el nombre si se cancela. Lo que jsdom no implementa —el top layer y el fondo inerte— se comprobó aparte en Chromium.
Hablemos
¿Preguntas, feedback o una visión distinta? Me encantaría leerte. Me encuentras aquí:
- GitHub — hgomezrobaina
- LinkedIn — Héctor Gómez Robaina
- X — @hgomezrobaina



Top comments (0)