2026年8月4日

React useDisclosure Hook:管理模態框和抽屜的開關狀態 (2026)

每個 React 應用都會逐漸累積各種可切換的 UI——確認對話框、行動端導航抽屜、設定彈出框、通知面板。它們背後的狀態始終相同:一個布林值、一個開啟方法、一個關閉方法,可能再加一個狀態變化時觸發埋點或焦點管理的回呼。於是你寫了 useState(false) 加三個內聯處理函式,複製貼上到下一個模態框,到第五個可切換元件的時候,你發現同樣的五行模式散落在十幾個檔案裡,沒有複用,也沒有生命週期鉤子。

useDisclosure(來自 @reactuses/core)將這一模式提取為一次性解決方案:預設非受控,需要時可切換為受控模式,提供 onOpen / onClose / onChange 回呼在恰當的時機觸發。回傳的處理函式透過 ref 實現參考穩定,不會導致子元件不必要的重新渲染。本文介紹 API、內部實作、受控與非受控的契約,以及模態框、抽屜和組合式多重 disclosure UI 的實際模式。TypeScript 優先。

最簡單的用法:模態框切換

import { useDisclosure } from '@reactuses/core';

function App() {
  const { isOpen, onOpen, onClose } = useDisclosure();

  return (
    <>
      <button onClick={onOpen}>開啟設定</button>
      {isOpen && (
        <dialog open>
          <h2>設定</h2>
          <p>這裡是設定面板內容。</p>
          <button onClick={onClose}>關閉</button>
        </dialog>
      )}
    </>
  );
}

不需要 useState,不需要寫內聯的 () => setOpen(true) / () => setOpen(false),不需要糾結命名。Hook 回傳語義明確的具名函式——觸發器上用 onOpen,關閉按鈕上用 onClose。每次渲染回傳相同的函式參考(ref 穩定化),所以把 onClose 傳給 React.memo 包裹的子元件也不會破壞最佳化。

完整 API

const {
  isOpen,       // boolean — 目前狀態
  onOpen,       // () => void — 設為 true
  onClose,      // () => void — 設為 false
  onOpenChange, // () => void — 切換:關閉時呼叫 onOpen,開啟時呼叫 onClose
  isControlled, // boolean — 如果傳了 isOpen prop 則為 true
} = useDisclosure({
  defaultOpen,  // boolean — 初始狀態(僅非受控模式)
  isOpen,       // boolean — 傳入以進入受控模式
  onOpen,       // () => void — 開啟後觸發
  onClose,      // () => void — 關閉後觸發
  onChange,     // (isOpen: boolean | undefined) => void — 任何變化時觸發
});

所有欄位都是可選的。不傳任何參數呼叫 useDisclosure() 就能得到一個初始關閉的非受控切換,涵蓋大多數模態框和抽屜的需求。選項是為「僅僅一個布林值不夠用」的場景準備的。

生命週期回呼:當開啟和關閉有副作用時

布林切換不夠用的時刻,就是你的模態框不只是顯示和隱藏的時刻。真實的 disclosure 元件需要副作用:使用者開啟定價彈窗時發送埋點事件,抽屜開啟時捕獲焦點,關閉時恢復焦點,通知面板切換時啟動或停止後台輪詢。內聯處理函式會把這些邏輯分散到 JSX 各處:

// 沒有 useDisclosure 時——副作用與 JSX 糾纏在一起
<button onClick={() => {
  setIsOpen(true);
  analytics.track('pricing_modal_opened');
  focusTrap.activate();
}}>
  查看定價
</button>

使用 useDisclosure,副作用集中在 Hook 呼叫處:

const { isOpen, onOpen, onClose } = useDisclosure({
  onOpen() {
    analytics.track('pricing_modal_opened');
    focusTrap.activate();
  },
  onClose() {
    analytics.track('pricing_modal_closed');
    focusTrap.deactivate();
  },
});

// JSX 變得簡潔
<button onClick={onOpen}>查看定價</button>

回呼在狀態更新之後觸發——onOpenisOpen 變為 true 時執行,onClose 在變為 false 時執行。onChange 在每次狀態轉換時觸發並傳入新值,適用於需要一個處理函式涵蓋兩個方向的場景(如同步到 URL 參數或外部 store)。

回呼 props 內部透過 useLatest 包裝——你可以傳入內聯箭頭函式而不會導致回傳的 onOpen / onClose 獲得新的參考。處理函式即使回呼變化也保持參考穩定。

受控模式:由父元件掌控狀態

有時開啟狀態屬於父元件或狀態管理器,disclosure 元件只負責渲染。傳入 isOpen prop,Hook 就會切換到受控模式:

function ControlledDrawer({ isOpen, onToggle }: Props) {
  const disclosure = useDisclosure({
    isOpen,
    onOpen: onToggle,
    onClose: onToggle,
  });

  // disclosure.isControlled === true
  // disclosure.isOpen 反映 prop 的值
  // disclosure.onOpen / onClose 觸發父元件的 onToggle

  return (
    <aside className={disclosure.isOpen ? 'open' : ''}>
      <button onClick={disclosure.onClose}>×</button>
      {/* 抽屜內容 */}
    </aside>
  );
}

受控模式下,onOpenonClose 不會更新內部狀態——Hook 尊重 prop 作為資料來源。它們只觸發回呼,讓父元件決定接下來做什麼。isControlled 標誌暴露出來以便你在需要時進行分支判斷,不過實務中很少需要檢查它。

兩種模式的邊界很清晰:如果 isOpenundefined(或未傳),Hook 是非受控的。如果是布林值——即使是 false——Hook 就是受控的。不存在「半受控」的灰色地帶。

onOpenChange:切換簡寫

很多 UI 框架暴露單一的 onOpenChange 回呼而非分開的 open/close 處理函式。useDisclosure 回傳的 onOpenChange 函式就是一個切換器:disclosure 關閉時呼叫 onOpen,開啟時呼叫 onClose。它可以直接映射到暴露單一回呼的元件:

const { isOpen, onOpenChange } = useDisclosure();

// 適配 Radix 風格的 API
<Dialog.Root open={isOpen} onOpenChange={onOpenChange}>
  <Dialog.Trigger>開啟</Dialog.Trigger>
  <Dialog.Content>...</Dialog.Content>
</Dialog.Root>

// 也可用作切換按鈕的處理函式
<button onClick={onOpenChange}>
  {isOpen ? '隱藏' : '顯示'}篩選器
</button>

切換不是第三條狀態路徑——它委託給同一套觸發回呼的 onOpen / onClose。一次轉換,一個回呼,一條程式碼路徑。

內部實作

完整實作很簡短:

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 };
}

三個建構模組:

  1. useControlled — 在內部 useState 和外部 prop 之間切換的 Hook。
  2. useLatest — 把回呼 props 包裝在 ref 中,使回傳的處理函式參考穩定。
  3. 受控守衛if (!isControlled) setIsOpen(...) 確保 Hook 不會與父元件的狀態衝突。

沒有 effect,沒有訂閱,沒有瀏覽器 API。Hook 天然 SSR 安全——純 React 狀態。

useDisclosure vs useBoolean vs useToggle

@reactuses/core 有三個管理布林值的 Hook,適用場景如下:

useDisclosureuseBooleanuseToggle
回傳值{ isOpen, onOpen, onClose, onOpenChange, isControlled }[value, { toggle, setTrue, setFalse }][value, toggle, setValue]
受控模式支援(isOpen prop)不支援不支援
生命週期回呼onOpenonCloseonChange
處理函式穩定性透過 useLatest ref 穩定化標準 useCallback標準 useCallback
最適合模態框、抽屜、彈出框——有開啟/關閉語義和副作用的場景簡單的顯示/隱藏標誌,不需要回呼極簡布林切換;非布林交替('asc' / 'desc'

如果不需要回呼或受控模式,useBooleanuseToggle 更輕量。useDisclosure 在開啟和關閉本身攜帶超越布林值的含義時才真正發揮作用。

實際模式

確認對話框:支援 Escape 和遮罩層關閉

function DeleteButton({ onConfirm }: { onConfirm: () => void }) {
  const { isOpen, onOpen, onClose } = useDisclosure();

  return (
    <>
      <button onClick={onOpen}>刪除</button>
      {isOpen && (
        <div className="overlay" onClick={onClose}>
          <div className="dialog" onClick={e => e.stopPropagation()}>
            <p>確定要刪除嗎?</p>
            <button onClick={() => { onConfirm(); onClose(); }}>
              是的,刪除
            </button>
            <button onClick={onClose}>取消</button>
          </div>
        </div>
      )}
    </>
  );
}

多個 Disclosure 互斥

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)}>一般</button>
      <button onClick={() => openExclusive(security)}>安全性</button>
      <button onClick={() => openExclusive(notifications)}>通知</button>

      {general.isOpen && <GeneralSettings />}
      {security.isOpen && <SecuritySettings />}
      {notifications.isOpen && <NotificationSettings />}
    </div>
  );
}

每個區段有自己的 useDisclosureopenExclusive 輔助函式先關閉所有,再開啟一個——不需要手風琴函式庫就能實現手風琴行為。

從 Chakra UI 遷移

如果你用過 Chakra UI 的 useDisclosure,API 幾乎一樣。主要差異:

  • 沒有 getButtonProps / getDisclosureProps — 這個 Hook 管理狀態,不管理 DOM 屬性。直接使用 isOpenonOpen / onClose
  • onOpenChange 而非 onToggle — 行為相同(切換),名稱不同,與 Radix、Headless UI、Ariakit 的命名慣例一致。
  • onChange 回呼 — Chakra 不暴露這個;@reactuses/core 提供,用於同步布林值到外部 store。
  • 不依賴 UI 框架 — 安裝 @reactuses/core,搭配任何元件庫使用,或者不搭配。

遷移就是一次重新命名。

重點總結

  • useDisclosure 替代了 useState(false) + 三個內聯處理函式的模式——你的每個模態框、抽屜、彈出框裡都有的那個。
  • 生命週期回呼(onOpenonCloseonChange)集中管理副作用——埋點、焦點管理、動畫觸發——遠離 JSX。
  • 受控模式可選:傳入 isOpen,Hook 聽從你的狀態;不傳,Hook 自己管理。
  • 處理函式參考穩定——onOpenonCloseonOpenChange 跨渲染保持同一參考,可安全傳給 memo 化的子元件。
  • onOpenChange 是切換函式,委託給 onOpen / onClose,直接映射到 Radix、Headless UI、Ariakit 的單回呼 API。
  • 天然 SSR 安全——沒有瀏覽器 API,沒有 effect,純 React 狀態。

@reactuses/core 取得,不要再複製貼上模態框狀態了。