Open state: two roots
The DOM owns open state. React does not mirror it, and in the common case does not participate in it at all.
That is the inversion the library rests on, and it is why there are two roots
instead of one root with an optional open prop.
Dialog.Root — the default
From @apostel/bedrock. The browser opens and closes it. React holds nothing.
<Dialog.Root defaultOpen={false} onOpenChange={(open) => …}>
| prop | type | notes |
|---|---|---|
defaultOpen |
boolean |
Read once, on mount. |
onOpenChange |
(open: boolean) => void |
Read-only. A toggle listener. |
onOpenChange here reports; it cannot refuse. That covers the case that gets
miscategorised as "controlled" more often than any other:
<Dialog.Root onOpenChange={(open) => { if (!open) form.reset() }}>
If that is what you need — tell me when it closed — you do not need the controlled entry point, and you should not pay for it.
Dialog.Root from /controlled — React gets a veto
import { Dialog } from '@apostel/bedrock/controlled'
<Dialog.Root open={open} onOpenChange={setOpen}>
{/* identical children, identical props on every part */}
</Dialog.Root>
| prop | type | notes |
|---|---|---|
open |
boolean |
Required. Decides. |
onOpenChange |
(open: boolean) => void |
Fires whether or not you accept. |
Swapping is one import line. Every child part is byte-identical under both roots and cannot tell which one it is under — that is what makes the split save bytes rather than move them.
What "controlled" means here
Not React owns the state. DOM leads, React vetoes:
- The user clicks.
beforetogglefires. If youropenprop disagrees and the event is cancelable, it is prevented and nothing moved. onOpenChangefires either way. You decide.- If
openchanges without user interaction, an effect moves the DOM to match.
For <dialog> in Chrome 141, measured rather than assumed:
| transition | hook | cancelable | so a refusal is… |
|---|---|---|---|
| closed → open | beforetoggle |
yes | invisible; it never opens |
| open → closed | beforetoggle |
no | — |
Escape, Dialog.Close |
cancel |
yes | invisible; it stays open |
Both directions are genuinely vetoable, so there is no visible flicker for
Dialog. Primitives whose platform hooks are not cancelable fall back to step 3
alone, which means one frame of visible movement in the refusal case only.
That is documented per primitive, and it is not fixed with flushSync.
Refusing a close
const [open, setOpen] = useState(false)
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (!next && form.isDirty) return confirmDiscard() // refuse; it stays open
setOpen(next)
}}
>
Dialog.Close uses command="request-close", never command="close". close
skips the cancel event, which is the only cancelable close hook a <dialog>
has — using it would make the veto above impossible.
Bundle cost
Measured with esbuild, minified, gzipped, React external:
| import | gzip |
|---|---|
Dialog from @apostel/bedrock |
1.95 kB |
Dialog from @apostel/bedrock/controlled |
2.09 kB |
@radix-ui/react-dialog |
13.7 kB |
The gap between the two entry points is the whole cost of controlled mode for a Dialog. It is much smaller for the menu family, where both roots pull in the same roving module — see gaps.
The two entry points are separate module graphs. If nothing in your app imports
/controlled, none of the reconciliation code is in your bundle — that is a
guarantee from the exports map, checked in CI by npm run lint:graph, not a
tree-shaking hope.