Skip to main content

Command Palette

Search for a command to run...

React useDisclosure Hook: Manage Modal & Drawer State (2026)

Updated
•6 min read•View as Markdown
R
Maintainer of @reactuses/core — 110+ essential React Hooks: SSR-safe, TypeScript-first, tree-shakeable. Docs & interactive demos: https://reactuse.com · MIT-licensed and maintained in spare time — sponsorships keep it going: https://github.com/sponsors/childrentime

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:

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