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:

Loading demo…
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 the exports map, checked in CI by npm run lint:graph, not a tree-shaking hope.

What open means

Not React owns the state. The DOM leads and React vetoes:

  1. The user acts. The platform's beforetoggle fires. If your open prop disagrees and the event is cancelable, it is prevented and nothing moved.
  2. onOpenChange fires either way. You decide.
  3. If open changes 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 why Close parts use command="request-close" and never command="close". close skips the cancel event, 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?.