DEV Community

reactuse.com
reactuse.com

Posted on • Originally published at reactuse.com

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>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

How It Works Inside

Three building blocks:

  1. useControlled — switches between internal useState and an external prop.
  2. useLatest — wraps callback props in a ref so the returned handlers have stable identities.
  3. The controlled guardif (!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>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.
  • onOpenChange instead of onToggle — matches Radix/Headless UI naming.
  • onChange callback 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 isOpen and the hook defers to your state.
  • Handlers are ref-stabilized — safe to pass to memoized children.
  • onOpenChange is 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)