Controlled state
Every primitive here opens and closes on its own. The trigger is bound to the panel by the parser, the browser moves it, and React is told afterwards. You do not wire anything up to make that work, and most components never need this page.
Reach for it when React has to refuse a move: a dialog that will not close over an unsaved form, a popover whose open state lives in a URL.
Two entry points
onOpenChange on the default import reports. It cannot refuse:
import { Popover } from '@apostel/bedrock'
<Popover.Root onOpenChange={(open) => { if (!open) clearFilters() }}>
That covers tell me when it closed, which is the case most often mistaken for
needing control. Both dialogs below run the same onOpenChange, declining every
close while the box is ticked. The import line is the only difference between
them:
Sourcerefusal.tsx
import { useState, type ReactNode } from 'react'
import { Dialog as ControlledDialog } from '../../src/controlled'
import { Dialog } from '../../src/index'
/**
* The two entry points, refusing the same close.
*
* Both lanes run the identical `onOpenChange`: if the box is ticked, decline.
* The only difference is the import line at the top of the file, which is the
* claim the page opens with and the one worth feeling rather than reading.
*
* On the left, `onOpenChange` is a `toggle` listener. It is told the dialog
* closed, because it did; there was never a decision to take part in. On the
* right the same callback is consulted before `<dialog>`'s `cancel` completes,
* so declining means nothing moves at all.
*
* The counter is not decoration. A refusal on the right is invisible by design,
* which makes it indistinguishable from a click that never registered until
* something says out loud that it happened.
*
* It sits outside the dialog and the switch sits inside, which is not a layout
* preference either way. The left dialog is gone by the time its verdict is
* worth reading, so a counter inside it would unmount with the sentence the
* lane exists to deliver. The switch has the opposite problem: a modal dialog
* makes the rest of the page inert, so a reader who opened the right-hand one
* with the switch outside could decline forever and never reach the switch that
* would let them out. Inside is also where the real version lives, since the
* unsaved form is in the dialog.
*/
function Lock({ locked, setLocked }: { locked: boolean; setLocked(next: boolean): void }) {
return (
<label className="rf-lock">
<input
type="checkbox"
checked={locked}
onChange={(event) => setLocked(event.target.checked)}
/>
the form has unsaved changes
</label>
)
}
function Lane({
title,
importLine,
verdict,
children,
}: {
title: string
importLine: string
verdict: string
children(props: { locked: boolean; decline(): void; controls: ReactNode }): ReactNode
}) {
const [locked, setLocked] = useState(true)
const [refusals, setRefusals] = useState(0)
return (
<div className="rf-lane">
<p className="rf-head">
<strong>{title}</strong>
<code>{importLine}</code>
</p>
<div className="rf-stage">
{children({
locked,
decline: () => setRefusals((count) => count + 1),
controls: <Lock locked={locked} setLocked={setLocked} />,
})}
</div>
<p className="rf-count" aria-live="polite">
{refusals === 0 ? 'Nothing declined yet.' : `Declined ${refusals}×. ${verdict}`}
</p>
</div>
)
}
export default function RefusalDemo() {
const [controlled, setControlled] = useState(false)
return (
<div className="rf">
<Lane title="Reports" importLine="from '@apostel/bedrock'" verdict="It closed anyway.">
{({ locked, decline, controls }) => (
// No `open` prop to pass: the default root does not take one, which is
// the whole difference. There is no state here for a refusal to hold.
<Dialog.Root
onOpenChange={(next) => {
if (!next && locked) decline()
}}
>
<Dialog.Trigger>Open, then press Escape</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Unsaved changes</Dialog.Title>
<Dialog.Description>
Declining here is a callback returning early. The browser had already closed the
dialog before it ran.
</Dialog.Description>
{controls}
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Content>
</Dialog.Root>
)}
</Lane>
<Lane
title="Refuses"
importLine="from '@apostel/bedrock/controlled'"
verdict="It is still open."
>
{({ locked, decline, controls }) => (
<ControlledDialog.Root
open={controlled}
onOpenChange={(next) => {
if (!next && locked) return decline()
setControlled(next)
}}
>
<ControlledDialog.Trigger>Open, then press Escape</ControlledDialog.Trigger>
<ControlledDialog.Content>
<ControlledDialog.Title>Unsaved changes</ControlledDialog.Title>
<ControlledDialog.Description>
Escape and Cancel are both declined while the box is ticked, and nothing flickers:
the browser asks before it closes.
</ControlledDialog.Description>
{controls}
<ControlledDialog.Close>Cancel</ControlledDialog.Close>
</ControlledDialog.Content>
</ControlledDialog.Root>
)}
</Lane>
</div>
)
}If you need a veto, change the import line:
import { Popover } from '@apostel/bedrock/controlled'
<Popover.Root open={open} onOpenChange={setOpen}>
Every child part is byte-identical under both, and takes the same props, so nothing inside the root changes when you switch.
| root | open |
defaultOpen |
onOpenChange |
|---|---|---|---|
| default | not accepted | read once, on mount, where the primitive has one | reports; cannot refuse |
/controlled |
required; decides | not accepted | fires whether or not you accept |
Not every primitive has both roots: Popover has no defaultOpen at all
(a popover cannot be shown before its element is connected), and the value-based
ones (Tabs, Accordion, Checkbox, RadioGroup, Toggle) control a
value or checked rather than an open. Each primitive's own page says which
it takes.
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 theexportsmap, checked in CI bynpm run lint:graph, not a tree-shaking hope.
What open means
Not React owns the state. The DOM leads and React vetoes:
- The user acts. The platform's
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.
Step 1 is what makes a refusal invisible, and not every element offers it. Where
it is missing there is no refusal at all: the DOM moves, onOpenChange tells you
it moved, and it stays moved. Step 3 does not stand in for it — it is keyed on
the prop, and declining is exactly the case where the prop does not change.
Which of the two you get is per element rather than per component:
| built on | cancelable hook | a refusal is… |
|---|---|---|
popover: Popover, DropdownMenu, Tooltip, HoverCard |
beforetoggle, both directions |
invisible; nothing moves |
<dialog>: Dialog, AlertDialog |
beforetoggle opening, cancel closing |
invisible; nothing moves |
<details>: Collapsible, Accordion |
none | not possible: it stays where the browser put it, and open disagrees until you change it |
So on a <details>-backed primitive, treat /controlled as one-way: change
open and the disclosure follows, but do not write a guard in onOpenChange
and expect it to hold. The veto you want is on the trigger's click, where the
toggle is still the default action and is still cancelable:
<Collapsible.Trigger
onClick={(event) => {
if (form.isDirty) event.preventDefault() // the disclosure never moves
}}
>
That works for pointer and keyboard alike, because Enter and Space on a
<summary> dispatch the same click.
On the elements that do have a hook, refusing a close reads the same:
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (!next && form.isDirty) return confirmDiscard() // refuse; it stays open
setOpen(next)
}}
>
<dialog>'s close veto is whyCloseparts usecommand="request-close"and nevercommand="close".closeskips thecancelevent, which is the only cancelable close hook a<dialog>has.
Bundle cost
The gap between an entry-point pair is the whole cost of controlled mode for that primitive. Measured with esbuild, minified, gzipped, React external:
| import | gzip |
|---|---|
Dialog from @apostel/bedrock |
1.95 kB |
Dialog from @apostel/bedrock/controlled |
2.09 kB |
It is smaller again for the menu family, where both roots pull in the same roving module. For per-primitive figures across the library, see should you switch?.