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>
回呼在狀態更新之後觸發——onOpen 在 isOpen 變為 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>
);
}
受控模式下,onOpen 和 onClose 不會更新內部狀態——Hook 尊重 prop 作為資料來源。它們只觸發回呼,讓父元件決定接下來做什麼。isControlled 標誌暴露出來以便你在需要時進行分支判斷,不過實務中很少需要檢查它。
兩種模式的邊界很清晰:如果 isOpen 是 undefined(或未傳),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 };
}
三個建構模組:
useControlled— 在內部useState和外部 prop 之間切換的 Hook。useLatest— 把回呼 props 包裝在 ref 中,使回傳的處理函式參考穩定。- 受控守衛 —
if (!isControlled) setIsOpen(...)確保 Hook 不會與父元件的狀態衝突。
沒有 effect,沒有訂閱,沒有瀏覽器 API。Hook 天然 SSR 安全——純 React 狀態。
useDisclosure vs useBoolean vs useToggle
@reactuses/core 有三個管理布林值的 Hook,適用場景如下:
useDisclosure | useBoolean | useToggle | |
|---|---|---|---|
| 回傳值 | { isOpen, onOpen, onClose, onOpenChange, isControlled } | [value, { toggle, setTrue, setFalse }] | [value, toggle, setValue] |
| 受控模式 | 支援(isOpen prop) | 不支援 | 不支援 |
| 生命週期回呼 | onOpen、onClose、onChange | 無 | 無 |
| 處理函式穩定性 | 透過 useLatest ref 穩定化 | 標準 useCallback | 標準 useCallback |
| 最適合 | 模態框、抽屜、彈出框——有開啟/關閉語義和副作用的場景 | 簡單的顯示/隱藏標誌,不需要回呼 | 極簡布林切換;非布林交替('asc' / 'desc') |
如果不需要回呼或受控模式,useBoolean 或 useToggle 更輕量。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>
);
}
每個區段有自己的 useDisclosure。openExclusive 輔助函式先關閉所有,再開啟一個——不需要手風琴函式庫就能實現手風琴行為。
從 Chakra UI 遷移
如果你用過 Chakra UI 的 useDisclosure,API 幾乎一樣。主要差異:
- 沒有
getButtonProps/getDisclosureProps— 這個 Hook 管理狀態,不管理 DOM 屬性。直接使用isOpen和onOpen/onClose。 onOpenChange而非onToggle— 行為相同(切換),名稱不同,與 Radix、Headless UI、Ariakit 的命名慣例一致。onChange回呼 — Chakra 不暴露這個;@reactuses/core提供,用於同步布林值到外部 store。- 不依賴 UI 框架 — 安裝
@reactuses/core,搭配任何元件庫使用,或者不搭配。
遷移就是一次重新命名。
重點總結
useDisclosure替代了useState(false)+ 三個內聯處理函式的模式——你的每個模態框、抽屜、彈出框裡都有的那個。- 生命週期回呼(
onOpen、onClose、onChange)集中管理副作用——埋點、焦點管理、動畫觸發——遠離 JSX。 - 受控模式可選:傳入
isOpen,Hook 聽從你的狀態;不傳,Hook 自己管理。 - 處理函式參考穩定——
onOpen、onClose、onOpenChange跨渲染保持同一參考,可安全傳給 memo 化的子元件。 onOpenChange是切換函式,委託給onOpen/onClose,直接映射到 Radix、Headless UI、Ariakit 的單回呼 API。- 天然 SSR 安全——沒有瀏覽器 API,沒有 effect,純 React 狀態。
從 @reactuses/core 取得,不要再複製貼上模態框狀態了。