· 2 min read
A portalled <dialog> does not fire React's onClose
Closing the modal left the app permanently frozen. The event was firing. React just was not listening for it.

I used the native <dialog> element for a preview modal. It gives you the top layer, the backdrop, focus trapping and Escape-to-close for free, which is a lot of accessibility work you do not have to write.
Then closing it broke the page. Not visibly — the dialog went away — but the animation behind it never resumed, and every subsequent interaction was dead.
<dialog ref={ref} onClose={() => setOpen(null)}>
setOpen(null) never ran. State stayed stuck on the item that had been open, and the code that reads that state kept believing the modal was up.
Why the handler never fires
React implements most events with a single listener at the root of its tree and synthetic dispatch downward. That works because the DOM event bubbles up to that root.
Two things break it here.
The dialog is rendered through createPortal to document.body, so the DOM node is outside React's container even though the React element is inside the tree. React handles that for most events by re-dispatching along the React tree rather than the DOM tree.
But close does not bubble. It fires on the dialog and stops. There is nothing to catch at any ancestor, in either tree.
So the event fires, correctly, on an element nobody is listening to.
The fix
Attach the listener to the node yourself:
useEffect(() => {
const node = ref.current;
if (!node) return;
const onClose = () => setOpen(null);
node.addEventListener("close", onClose);
return () => node.removeEventListener("close", onClose);
}, []);
Nine lines, no library, and it works whether the dialog was closed by Escape, by a form with method="dialog", or by calling close().
The other one that bit me
Before this I had the dialog rendered in place rather than portalled, and it opened somewhere off screen.
<dialog> positions itself in the top layer, but a transform on any ancestor creates a containing block for fixed-position descendants, and the dialog is positioned relative to that instead of the viewport. This site animates transforms on section wrappers, so a modal inside one was being positioned against a moving box.
Portalling to document.body fixes it, because there is no transformed ancestor left. Which is how I arrived at the first bug: the fix for the positioning problem is what created the event problem.
Worth knowing before you reach for it
Native <dialog> is genuinely good and I would use it again. But it is a browser primitive with browser semantics, and two of them do not match what a React developer expects: close does not bubble, and the top layer is not immune to transformed ancestors. Neither produces an error. Both produce a component that looks fine and is quietly broken.