昨天我們照 Downshift 的 useSelect 臨摹了一個下拉選單:一個 Hook 交回一包 prop getter,呼叫端把它們展開到自己寫的元素上。DOM 歸呼叫端所管,但合約完不完整,也落在呼叫端的紀律上。在按鈕上自己多掛一個 onKeyDown,或是忘了在選項上展開 getItemProps,合約就斷了,而且沒有任何東西會出聲。
回頭看,在昨天的臨摹裡,它之所以安靜,是因為狀態機和把事件送進狀態機的處理器,被包在同一個 Hook 裡一起交出來。呼叫端拿得到處理器,卻拿不到狀態機本身。所以處理器一被蓋掉,狀態機就沒有別的入口了。
React Aria 的作法是把這兩件事拆開:react-stately 管狀態,@react-aria/* 管行為,兩層之間用一個物件接起來。
可是一旦拆成兩層,呼叫端就多了一個選擇:要一路用到行為層,還是只拿狀態層,其餘自己接?
分層之後,「要切在哪一層」這個決定,歸誰?
今天讓我們把昨天的 useSelect 拆成這兩層,看看昨天那個靜默失效,會變成什麼。
進入程式碼之前,先把昨天和今天的結構並排來看。圖裡的箭頭,表示誰把什麼交給誰:

昨天,處理器和狀態機都在 useSelect 裡面,呼叫端只拿得到它交出來的 prop getter。
今天,狀態機搬到狀態層的 useSelectState,由它交出一個 state 物件。行為層的 useSelect 與 useOption 收下這個物件,再把它變成按鈕、清單與選項的 props。
兩層之間只隔著這個物件,而這個物件,呼叫端自己也拿得到。
先從狀態層開始。狀態層用 useReducer 跑昨天那份合約的狀態機 transition,交回一個物件 SelectState:
export interface SelectState<T> extends ListboxState {
readonly items: readonly T[];
readonly selectedItem: T | null;
send(event: ListboxEvent): void;
}
export function useSelectState<T>(items: readonly T[]): SelectState<T> {
const [state, send] = useReducer(
(current: ListboxState, event: ListboxEvent) =>
transition(current, event, items.length),
initialState,
);
return {
...state,
items,
selectedItem:
state.selectedIndex === null
? null
: (items[state.selectedIndex] ?? null),
send,
};
}
這一層沒有元素、沒有事件物件,也沒有 id。它只知道合約的狀態:選單開著沒有、目前項目是哪一項、選了哪一項。
跟昨天的 useSelect 不一樣的,是最後一個欄位 send。
昨天,把合約事件送進狀態機的入口藏在 getter 的處理器裡,呼叫端看不到。今天,它是 SelectState 上的一個方法,直接交到呼叫端手上。
state有了狀態層交回來的 SelectState 物件,接著,我們把它交給行為層的 Hook,作為參數使用。
比如說:useSelect(state) 交回按鈕與清單的 props:
export function useSelect<T>(state: SelectState<T>): SelectAria {
const ids = createIds(useId());
idsByState.set(state, ids);
return {
triggerProps: {
...triggerAttributes(state, ids),
onClick: () => state.send({ type: "TriggerClick" }),
onKeyDown: (event) => {
if (contractKeys.has(event.key)) event.preventDefault();
state.send({ type: "TriggerKeyDown", key: event.key });
},
onBlur: () => state.send({ type: "TriggerBlur" }),
},
listboxProps: listboxAttributes(ids),
};
}
至於 useOption(state, index) 則交回一個選項的 props:
export function useOption<T>(
state: SelectState<T>,
index: number,
): OptionProps {
const ids = idsByState.get(state);
if (ids === undefined)
throw new Error("useOption 要在呼叫過 useSelect(state) 的元件之下使用");
return {
...optionAttributes(state, ids, index),
onClick: () => state.send({ type: "OptionClick", index }),
onMouseMove: () => state.send({ type: "OptionPointerMove", index }),
onMouseDown: (event) => event.preventDefault(),
};
}
它們做的事情,跟昨天的 getter 一樣:把合約的屬性展開,再加上把 DOM 事件翻成合約事件的處理器。按下選項時的 event.preventDefault() 也還在,焦點因此留在按鈕上。
不一樣的,是狀態從哪裡來。
昨天的 getter 從同一個 Hook 的閉包裡拿到狀態,呼叫端看不到這條線。今天,狀態是一個參數,寫在每一個 Hook 的簽章上。React Aria 的 useSelect 與 useOption 也是這個形狀,參數裡都有一個 state。
state兩個 Hook 裡都有一個 idsByState。它在解決一個昨天不存在的問題。
按鈕上的 aria-activedescendant 要指到目前項目的 id。昨天,按鈕與選項的 getter 出自同一個 Hook,共用同一組 id。但今天,useOption 會在另一個元件裡呼叫(下一節會看到為什麼),拿不到 useSelect 那邊用 useId 產生的 id。
兩邊共同握著的,只有 state。所以 id 也只好掛在它身上傳過去:
const idsByState = new WeakMap<SelectState<unknown>, ListboxIds>();
useSelect 用 idsByState.set(state, ids) 把 id 存進去,useOption 再用 idsByState.get(state) 讀出來。React Aria 的 listbox 也是這樣做的:useListBox 把 id 存進一個以 state 為鍵的 WeakMap,useOption 再從那裡讀出來。
連 id,都要經過兩層之間的這個物件。
呼叫端要做的事情變成三步:用 useSelectState 拿到 state,把它交給 useSelect 拿到按鈕與清單的 props,再交給每一個選項的 useOption:
import type { JSX } from "react";
import { useOption, useSelect } from "../behavior";
import { type SelectState, useSelectState } from "../state";
const Option = ({
state,
index,
}: {
state: SelectState<string>;
index: number;
}): JSX.Element => {
const optionProps = useOption(state, index);
return <li {...optionProps}>{state.items[index]}</li>;
};
export const Select = ({
items,
}: {
items: readonly string[];
}): JSX.Element => {
const state = useSelectState(items);
const { triggerProps, listboxProps } = useSelect(state);
return (
<div>
<button type="button" {...triggerProps}>
{state.selectedItem ?? "選一個"}
</button>
<ul {...listboxProps}>
{state.isOpen &&
items.map((item, index) => (
<Option key={item} state={state} index={index} />
))}
</ul>
</div>
);
};
跟昨天相比,多了一個 Option 元件。
這是因為 useOption 是一個 Hook。照 Hook 的規則,它不能在 items.map 的回呼裡呼叫,所以每一個選項都得是一個元件,state 再從 Select 以 prop 傳下去。
這也順帶決定了誰會被通知:state 活在 Select 的 useReducer 裡,每按一次方向鍵,七個 Option 都會跟著重新渲染。誰該被通知,在這裡是由交付的形狀決定的,這個問題我們在共享狀態與使用者輸入的領域已經看了八天,這裡先記一筆。
跟昨天一樣,把 <ul>/<li> 換成 <div role="listbox">/<div role="option">。兩份呼叫端都跑同一組焦點合約斷言,每份十條,兩份都是綠的。
再用 pnpm test 數兩份呼叫端的差異,只算程式碼行:
| 形狀 | 呼叫端程式碼行 | 拿掉 | 寫上 | 形狀層改了幾行 |
|---|---|---|---|---|
| 一疊行為層 | 34 | 3 | 3 | 0 |
拿掉的三行是:
L14 return <li {...optionProps}>{state.items[index]}</li>;
L30 <ul {...listboxProps}>
L35 </ul>
寫上的三行,是同樣位置的 <div>。
改的一樣是元素本身,只是散在兩個元件裡:選項的元素在 Option,清單的元素在 Select。昨天是四行,今天是三行,但這一行的差別來自排版,不是形狀:Option 回傳的 JSX 短到排成一行,<li> 的開標籤與閉標籤落在同一行上。換掉的,一樣是兩個元素。
useSelectState、useSelect 與 useOption 都一行沒改。「不決定你的 DOM」在這裡也兌現了,兌現的方式跟昨天差不多。
那麼,開頭那個問題呢?
先試著把 Option 寫錯。假使我們忘了把 state 傳給 Option:
<Option key={item} index={index} />
型別檢查會先擋下來:
error TS2741: Property 'state' is missing in type '{ index: number; }' but required in type '{ state: SelectState<string>; index: number; }'.
再假使 state 傳了,但 Select 裡沒有呼叫 useSelect(state)。這時 useOption 在 idsByState 裡找不到 id,渲染時就會丟出錯誤:
useOption 要在呼叫過 useSelect(state) 的元件之下使用
這兩種錯誤,都發生在兩層之間的那條線上。
昨天,選項的 getter 與按鈕的 getter 出自同一次 Hook 呼叫。它們屬於同一份合約,這件事不必寫出來,也就沒有地方可以檢查。今天,這條線寫在每一個 Hook 的參數上,寫錯了,型別或執行期會出聲。
但再把昨天第一個情況重做一次,在展開 triggerProps 之後自己掛一個 onKeyDown:
<button
type="button"
{...triggerProps}
onKeyDown={(event) => {
if (event.key === "Tab") console.log("離開選單");
}}
>
結果跟昨天一樣。型別檢查通過,Tab 會印出「離開選單」,滑鼠點擊也正常,可是方向鍵打不開選單,也沒有任何東西會出聲。
接縫出現在狀態層與行為層之間。props 與元素之間,仍然只隔著一個展開運算子。
這是這個形狀贏的地方。昨天那份靠紀律維持的接線,有一段變成了一個看得見的物件:每一個行為層的 Hook 都得拿到 state 才叫得起來,而 state 是誰、從哪裡來,寫在程式碼裡。
既然狀態層是一個可以單獨拿到的物件,呼叫端也就可以決定,不要上面那一層。
例如,我不想為了 useOption 多寫一個 Option 元件,想像昨天那樣,在 items.map 裡直接把選項畫出來。按鈕的部分,useSelect 用得好好的,那就只把選項那一半換掉。先自己用 useId 產生一組 id:
const state = useSelectState(items);
const { triggerProps, listboxProps } = useSelect(state);
const ids = createIds(useId());
再用合約的 optionAttributes 與 state.send,在 items.map 裡組出選項的 props:
<li
key={item}
{...optionAttributes(state, ids, index)}
onClick={() => state.send({ type: "OptionClick", index })}
onMouseMove={() => state.send({ type: "OptionPointerMove", index })}
onMouseDown={(event) => event.preventDefault()}
>
{item}
</li>
型別檢查通過,方向鍵也能動。但按兩下方向鍵之後,看一下按鈕與選項上的 id:
按鈕的 aria-activedescendant:_r_2_-option-1
第 1、2 項的 id:_r_3_-option-0、_r_3_-option-1
按鈕指向的 id,不存在於頁面上的任何元素。
這正是昨天第二個情況:忘了展開 getItemProps,aria-activedescendant 指向一個不存在的 id。它回來了,而且一樣安靜。
原因在 idsByState。按鈕的 id 是 useSelect 自己用 useId 產生、存在 WeakMap 裡的,呼叫端拿不到。我們另外產生的那一組,跟它對不上。
所以,要不用 useOption,就得連 useSelect 一起不用,只拿狀態層,其餘全部自己組:
import type { JSX } from "react";
import { useId } from "react";
import {
contractKeys,
createIds,
listboxAttributes,
optionAttributes,
triggerAttributes,
} from "../../../core/src";
import { useSelectState } from "../state";
export const Select = ({
items,
}: {
items: readonly string[];
}): JSX.Element => {
const state = useSelectState(items);
const ids = createIds(useId());
return (
<div>
<button
type="button"
{...triggerAttributes(state, ids)}
onClick={() => state.send({ type: "TriggerClick" })}
onKeyDown={(event) => {
if (contractKeys.has(event.key)) event.preventDefault();
state.send({ type: "TriggerKeyDown", key: event.key });
}}
onBlur={() => state.send({ type: "TriggerBlur" })}
>
{state.selectedItem ?? "選一個"}
</button>
<ul {...listboxAttributes(ids)}>
{state.isOpen &&
items.map((item, index) => (
<li
key={item}
{...optionAttributes(state, ids, index)}
onClick={() => state.send({ type: "OptionClick", index })}
onMouseMove={() =>
state.send({ type: "OptionPointerMove", index })
}
onMouseDown={(event) => event.preventDefault()}
>
{item}
</li>
))}
</ul>
</div>
);
};
這個版本跑同一組焦點合約斷言,十條全綠,Option 元件也不見了。但呼叫端從 34 行變成 50 行,而且多出來的不只是行數:哪些鍵要 preventDefault、按下選項時為什麼要攔住 mousedown、失焦時要送出 TriggerBlur、按鈕與選項的 id 要從同一個 useId 長出來,這些原本是行為層替我們記住的事,現在都得由呼叫端自己知道。
所以,回到今天的問題。要切在哪一層,是呼叫端決定的。
但可以切在哪裡,是分層模型先畫好的。
在狀態層與行為層之間可以切,因為兩層之間只隔著 state 這個物件。在行為層的中間就不行,因為 useSelect 與 useOption 之間,還有一份沒有寫在簽章上的約定:id 存在一個以 state 為鍵的 WeakMap 裡。只換掉其中一半,昨天的靜默失效就回來了。
這是這個形狀的缺點。要用得起來,得先知道它怎麼分層:每一層交出什麼、哪一個 Hook 要在哪一個之後呼叫、為什麼選項得是一個元件。而切得越低,要自己組裝的就越多。
昨天形狀的優點則是,抽象相對少許多:要理解它,讀一個 Hook 的回傳值就夠了。今天這段看得見的接縫,正是用那份「少」換來的。同一個性質,從昨天的優點,變成了今天的缺點。
不過,不管切在哪一層,最後把 props 展開到元素上的,都還是呼叫端。在按鈕上自己多掛一個 onKeyDown,今天一樣會讓方向鍵安靜地失效。
狀態與行為之間的線,交出來變成了一個物件;props 與元素之間的那條線,還留在呼叫端手上。那如果,連這條接線都不交給你呢?
臨摹的
useOption內部沒有再呼叫其他 Hook,所以在items.map裡呼叫其實跑得動;這裡照 Hook 的規則寫成一項一個元件。React Aria 的useOption內部會呼叫useSelectableItem、useHover等 Hook。useSelect(props, state, ref)、useOption(props, state, ref)的簽章,以及useListBox寫入、useOption讀取的listData(WeakMap<ListState, ListData>),依react-aria3.52.1 發布的原始碼,查核於 2026-10-06。本文對照
react-aria3.52.1、react-stately3.50.0,查核於 2026-10-06;實驗跑在react19.3.0 與react-dom19.3.0。