August 4, 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: calls onOpen if closed, onClose if open
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. That covers most modals and drawers. The options exist for the cases where “just a boolean” isn’t enough.
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. Inline handlers scatter this logic across JSX:
// 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 — onOpen runs when isOpen transitions to true, onClose when it transitions to false. onChange fires on every transition with the new value, for when you want one handler covering both directions (e.g. syncing to a URL param or external store).
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. The handlers stay referentially stable even if the callbacks change:
const { onOpen } = useDisclosure({
onOpen: () => console.log(someValueThatChanges),
});
// onOpen is the same function reference every render
This is why useDisclosure returns stable handlers where a plain useState + inline closures wouldn’t.
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.isOpen reflects the prop
// disclosure.onOpen / onClose fire the parent's onToggle
return (
<aside className={disclosure.isOpen ? 'open' : ''}>
<button onClick={disclosure.onClose}>×</button>
{/* drawer content */}
</aside>
);
}
In controlled mode, onOpen and onClose do not update internal state — the hook respects the prop as the source of truth. They only fire the callback, so the parent can decide what happens. The isControlled flag is exposed so you can branch logic if needed, though in practice you rarely check it.
The boundary between modes is clean: if isOpen is undefined (or not passed), the hook is uncontrolled. If it’s a boolean — even false — the hook is controlled. There’s no “semi-controlled” gray zone.
onOpenChange: The Toggle Shorthand
Many UI frameworks expose a single onOpenChange callback instead of separate open/close handlers. 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>
// Works as a toggle button handler
<button onClick={onOpenChange}>
{isOpen ? 'Hide' : 'Show'} filters
</button>
The toggle isn’t a third state path — it delegates to the same onOpen / onClose that fire your callbacks. One transition, one callback, one code path.
How It Works Inside
The full implementation is short:
import { useCallback } from 'react';
import { useControlled } from '../useControlled';
import { useLatest } from '../useLatest';
export function useDisclosure(props = {}) {
const {
defaultOpen,
isOpen: isOpenProp,
onClose: onCloseProp,
onOpen: onOpenProp,
onChange = () => {},
} = props;
const onOpenPropRef = useLatest(onOpenProp);
const onClosePropRef = useLatest(onCloseProp);
const [isOpen, setIsOpen] = useControlled(
isOpenProp,
defaultOpen || false,
onChange,
);
const isControlled = isOpenProp !== undefined;
const onClose = useCallback(() => {
if (!isControlled) setIsOpen(false);
onClosePropRef.current?.();
}, [isControlled, onClosePropRef, setIsOpen]);
const onOpen = useCallback(() => {
if (!isControlled) setIsOpen(true);
onOpenPropRef.current?.();
}, [isControlled, onOpenPropRef, setIsOpen]);
const onOpenChange = useCallback(() => {
(isOpen ? onClose : onOpen)();
}, [isOpen, onOpen, onClose]);
return { isOpen: !!isOpen, onOpen, onClose, onOpenChange, isControlled };
}
Three building blocks:
-
useControlled— a hook that switches between internaluseStateand an external prop. WhenisOpenPropisundefined, it manages its own state withdefaultOpenas the initial value. WhenisOpenPropis a boolean, it returns that value directly and the setter becomes a no-op.onChangefires on either path. -
useLatest— wraps theonOpenandonClosecallback props in a ref so their current value is always accessible without adding them to dependency arrays. This is why the returnedonOpenandonClosefunctions have stable identities — theiruseCallbackdeps don’t include the callback props themselves, only the ref containers. -
The controlled guard —
if (!isControlled) setIsOpen(...)ensures the hook never fights the parent. In controlled mode, callingonOpenfires the callback but leaves the state alone; the parent updatesisOpenin its own time, and the hook reflects the new value on the next render.
No effects, no subscriptions, no browser APIs. The hook is SSR-safe by construction — it’s pure React state. The server renders it, the client hydrates it, and nothing diverges.
useDisclosure vs useBoolean vs useToggle
@reactuses/core has three hooks that manage a boolean. Here’s when each one fits:
useDisclosure | useBoolean | useToggle | |
|---|---|---|---|
| Returns | { isOpen, onOpen, onClose, onOpenChange, isControlled } | [value, { toggle, setTrue, setFalse }] | [value, toggle, setValue] |
| Controlled mode | Yes (isOpen prop) | No | No |
| Lifecycle callbacks | onOpen, onClose, onChange | None | None |
| Stable handlers | Ref-stabilized via useLatest | Standard useCallback | Standard useCallback |
| Best for | Modals, drawers, popovers — anything with open/close semantics and side effects | Simple show/hide flags where you don’t need callbacks | Minimal boolean toggle; non-boolean alternation ('asc' / 'desc') |
If you don’t need callbacks or controlled mode, useBoolean or useToggle is lighter. useDisclosure earns its weight when opening and closing carry meaning beyond the boolean itself.
Patterns
Confirmation Dialog with Escape and Overlay Dismiss
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>
)}
</>
);
}
The overlay’s onClick calls onClose, the dialog stops propagation. The confirm button runs the action then closes. No state management code visible in the JSX.
Mobile Drawer with Animation Awareness
function MobileNav() {
const [isAnimating, setIsAnimating] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure({
onOpen: () => setIsAnimating(true),
onClose: () => setIsAnimating(true),
onChange: () => {
setTimeout(() => setIsAnimating(false), 300);
},
});
return (
<>
<button onClick={onOpen} disabled={isAnimating}>
Menu
</button>
<nav className={`drawer ${isOpen ? 'open' : 'closed'}`}>
<button onClick={onClose}>×</button>
{/* nav links */}
</nav>
</>
);
}
The onOpen / onClose callbacks set an animating flag to disable the trigger during transitions. onChange clears it after the CSS transition duration. All the timing logic is in one place.
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: ReturnType<typeof useDisclosure>) => {
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>
);
}
Each section gets its own useDisclosure. The openExclusive helper closes all, then opens one — accordion behavior without an accordion library.
Coming from Chakra UI
If you’ve used Chakra UI’s useDisclosure, the API is nearly identical. The main differences:
- No
getButtonProps/getDisclosureProps— this hook manages state, not DOM attributes. UseisOpenandonOpen/onClosedirectly. onOpenChangeinstead ofonToggle— same behavior (toggle), different name. Matches the naming convention used by Radix, Headless UI, and Ariakit.onChangecallback — Chakra doesn’t expose this;@reactuses/coredoes, for syncing the boolean to external stores.- No dependency on a UI framework — install
@reactuses/coreand use it with any component library or none.
// Chakra UI
const { isOpen, onOpen, onClose, onToggle } = useDisclosure();
// @reactuses/core — same shape, different toggle name
const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure();
Migration is a rename.
Takeaways
useDisclosurereplaces theuseState(false)+ three inline handlers pattern that accumulates across every modal, drawer, and popover in your app.- Lifecycle callbacks (
onOpen,onClose,onChange) centralize side effects — analytics, focus management, animation triggers — away from JSX. - Controlled mode is opt-in: pass
isOpenand the hook defers to your state; omit it and the hook manages its own. - Handlers are ref-stabilized —
onOpen,onClose, andonOpenChangekeep the same identity across renders, so they’re safe to pass to memoized children. onOpenChangeis a toggle that delegates toonOpen/onClose, mapping directly onto the single-callback API that Radix, Headless UI, and Ariakit use.- SSR-safe by construction — no browser APIs, no effects, pure React state.
Grab it from @reactuses/core and stop copy-pasting modal state.