React useDisclosure Hook: Manage Modal & Drawer State (2026)
React useDisclosure Hook: Manage Modal & Drawer State (2026)
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. Real disclosure widgets need side effects: send an analytics event when the user opens the pricing modal, trap focus when a drawer opens, restore focus when it closes, start or stop a background poll when a notification panel toggles.
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
Sometimes the open state belongs to a parent or a state manager, and the disclosure widget just renders it. 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
// disclosure.onOpen / onClose fire the parent's onToggle
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 between modes is clean: if isOpen is undefined, the hook is uncontrolled. If it's a boolean — even false — the hook is controlled.
onOpenChange: The Toggle Shorthand
useDisclosure returns an onOpenChange function that acts as a toggle: it calls onOpen when the disclosure is closed, and onClose when it's open. This maps directly onto components that expose a single callback:
const { isOpen, onOpenChange } = useDisclosure();
// Works with Radix-style APIs
<Dialog.Root open={isOpen} onOpenChange={onOpenChange}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Content>...</Dialog.Content>
</Dialog.Root>
How It Works Inside
The full implementation is short — 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. The hook is SSR-safe by construction — it's pure React state.
useDisclosure vs useBoolean vs useToggle
@reactuses/core has three hooks that manage a boolean:
- useDisclosure — controlled mode, lifecycle callbacks, ref-stabilized handlers. Best for modals, drawers, popovers.
- useBoolean — simple show/hide flags where you don't need callbacks.
- useToggle — minimal boolean toggle; also handles non-boolean alternation (
'asc'/'desc').
If you don't need callbacks or controlled mode, useBoolean or useToggle is lighter.
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— this hook manages state, not DOM attributes. onOpenChangeinstead ofonToggle— same behavior, matches Radix/Headless UI naming.onChangecallback exposed for syncing to external stores.- No dependency on a UI framework.
Migration is a rename.
Takeaways
- useDisclosure replaces the
useState(false)+ three inline handlers pattern 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 directly 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.
This post was originally published on reactuse.com.
