Every React app accumulates toggleable UI — a confirmation dialog, a mobile nav drawer, a settings popover, a notification panel. The state behind each one is always the same: a boolean, a way to open, a way to close, and maybe a callback for analytics or focus management when the transition happens. So you write useState(false) and three inline handlers, copy-paste it to the next modal, and somewhere around the fifth disclosure widget you notice you've scattered the same five-line pattern across a dozen files with nothing reusable and no lifecycle hooks.
useDisclosure from @reactuses/core is that pattern extracted once: uncontrolled by default, controlled when you need it, with onOpen / onClose / onChange callbacks that fire at exactly the right time. The returned handlers are ref-stabilized so they never cause downstream re-renders. This post walks the API, the internals, the controlled-vs-uncontrolled contract, and real patterns for modals, drawers, and composed multi-disclosure UIs. TypeScript-first.
The Simplest Case: A Modal Toggle
import { useDisclosure } from '@reactuses/core';
function App() {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<>
<button onClick={onOpen}>Open settings</button>
{isOpen && (
<dialog open>
<h2>Settings</h2>
<p>Your settings panel content here.</p>
<button onClick={onClose}>Close</button>
</dialog>
)}
</>
);
}
No useState, no inline () => setOpen(true) / () => setOpen(false), no naming decisions. The hook returns named functions whose intent is obvious in JSX — onOpen on the trigger, onClose on the dismiss button. It returns the same function identity on every render (ref-stabilized), so passing onClose to a memoized child component doesn't break React.memo.
The Full API
const {
isOpen, // boolean — current state
onOpen, // () => void — set to true
onClose, // () => void — set to false
onOpenChange, // () => void — toggle
isControlled, // boolean — true if you passed isOpen in props
} = useDisclosure({
defaultOpen, // boolean — initial state (uncontrolled mode only)
isOpen, // boolean — pass to enter controlled mode
onOpen, // () => void — fires after opening
onClose, // () => void — fires after closing
onChange, // (isOpen: boolean | undefined) => void — fires on any change
});
Every field is optional. Call useDisclosure() with no arguments and you get an uncontrolled toggle that starts closed.
Lifecycle Callbacks: When Opening and Closing Have Side Effects
A boolean toggle becomes insufficient the moment your modal does more than show and hide. Without useDisclosure — side effects tangled in JSX:
<button onClick={() => {
setIsOpen(true);
analytics.track('pricing_modal_opened');
focusTrap.activate();
}}>
View pricing
</button>
With useDisclosure, the side effects live in the hook call, co-located and centralized:
const { isOpen, onOpen, onClose } = useDisclosure({
onOpen() {
analytics.track('pricing_modal_opened');
focusTrap.activate();
},
onClose() {
analytics.track('pricing_modal_closed');
focusTrap.deactivate();
},
});
// JSX is now clean
<button onClick={onOpen}>View pricing</button>
The callbacks fire after the state updates. The callback props are wrapped in useLatest internally — meaning you can pass inline arrow functions without causing the returned onOpen / onClose to get new identities.
Controlled Mode: When the Parent Owns the State
Pass isOpen in props and the hook switches to controlled mode:
function ControlledDrawer({ isOpen, onToggle }: Props) {
const disclosure = useDisclosure({
isOpen,
onOpen: onToggle,
onClose: onToggle,
});
// disclosure.isControlled === true
return (
<aside className={disclosure.isOpen ? 'open' : ''}>
<button onClick={disclosure.onClose}>×</button>
</aside>
);
}
In controlled mode, onOpen and onClose do not update internal state — the hook respects the prop as the source of truth. The boundary is clean: isOpen is undefined → uncontrolled. isOpen is a boolean → controlled.
onOpenChange: The Toggle Shorthand
onOpenChange acts as a toggle: calls onOpen when closed, onClose when open. Maps directly onto Radix-style single-callback APIs:
const { isOpen, onOpenChange } = useDisclosure();
<Dialog.Root open={isOpen} onOpenChange={onOpenChange}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Content>...</Dialog.Content>
</Dialog.Root>
How It Works Inside
Three building blocks:
-
useControlled— switches between internaluseStateand an external prop. -
useLatest— wraps callback props in a ref so the returned handlers have stable identities. -
The controlled guard —
if (!isControlled) setIsOpen(...)ensures the hook never fights the parent.
No effects, no subscriptions, no browser APIs. SSR-safe by construction.
useDisclosure vs useBoolean vs useToggle
| useDisclosure | useBoolean | useToggle | |
|---|---|---|---|
| Controlled mode | Yes | No | No |
| Lifecycle callbacks |
onOpen, onClose, onChange
|
None | None |
| Stable handlers | Ref-stabilized | Standard useCallback
|
Standard useCallback
|
| Best for | Modals, drawers, popovers | Simple show/hide flags | Minimal boolean toggle |
Patterns
Confirmation Dialog
function DeleteButton({ onConfirm }: { onConfirm: () => void }) {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<>
<button onClick={onOpen}>Delete</button>
{isOpen && (
<div className="overlay" onClick={onClose}>
<div className="dialog" onClick={e => e.stopPropagation()}>
<p>Are you sure?</p>
<button onClick={() => { onConfirm(); onClose(); }}>
Yes, delete
</button>
<button onClick={onClose}>Cancel</button>
</div>
</div>
)}
</>
);
}
Multiple Disclosures with Mutual Exclusion
function SettingsPanel() {
const general = useDisclosure({ defaultOpen: true });
const security = useDisclosure();
const notifications = useDisclosure();
const closeAll = () => {
general.onClose();
security.onClose();
notifications.onClose();
};
const openExclusive = (target) => {
closeAll();
target.onOpen();
};
return (
<div>
<button onClick={() => openExclusive(general)}>General</button>
<button onClick={() => openExclusive(security)}>Security</button>
<button onClick={() => openExclusive(notifications)}>Notifications</button>
{general.isOpen && <GeneralSettings />}
{security.isOpen && <SecuritySettings />}
{notifications.isOpen && <NotificationSettings />}
</div>
);
}
Accordion behavior without an accordion library.
Coming from Chakra UI
The API is nearly identical. Main differences:
- No
getButtonProps/getDisclosureProps— manages state, not DOM attributes. -
onOpenChangeinstead ofonToggle— matches Radix/Headless UI naming. -
onChangecallback for syncing to external stores. - No UI framework dependency.
Migration is a rename.
Takeaways
-
useDisclosure replaces
useState(false)+ three inline handlers across every modal, drawer, and popover. - Lifecycle callbacks centralize side effects — analytics, focus management, animation triggers.
-
Controlled mode is opt-in: pass
isOpenand the hook defers to your state. - Handlers are ref-stabilized — safe to pass to memoized children.
-
onOpenChangeis a toggle mapping onto Radix/Headless UI/Ariakit single-callback APIs. - SSR-safe by construction — pure React state.
Grab it from @reactuses/core and stop copy-pasting modal state.
Top comments (0)