自訂 Hook 的設計與拆分——從複用邏輯到複用心智模型

很多人第一次自訂 Hook,出發點都差不多:
元件裡有三四個
useState多個
useEffect還有一點事件處理邏輯
程式碼越來越長,看著有些累
於是順手抽出一個 useXxx.ts,把程式碼搬過去。 檔案是變短了,但新的問題很快又來了:
這個 Hook 的輸入很多,回傳值也很多。
邏輯雖然搬走了,但讀起來還是亂。
子元件呼叫時,不知道哪些回傳值是「狀態」,哪些是「動作」,哪些只是內部實作。
這說明問題不在「有沒有抽 Hook」,而在於:
自訂 Hook 不是「把程式碼換個檔案放」,而是把一段狀態邏輯重新設計成一個穩定的介面。
如果元件是 UI 的組合單位,那自訂 Hook 就是狀態邏輯的組合單位。
一、先別急著抽:什麼時候值得做成自訂 Hook?
React 官方對 custom hook 的定義很樸素:
它是一個以
use開頭的函式;它內部可以呼叫其他 Hook;
它的目的,是在多個元件之間複用有狀態邏輯。
但在日常專案裡,「複用」只是其中一種理由。 真正常見的抽取時機,通常有三類:
1. 同一段狀態邏輯在多個元件裡重複出現
比如:
useWindowSizeuseOnlineStatususeDebouncedValueuseFetchUser
這種最直觀,就是典型的邏輯複用。
2. 一個元件裡的 Hooks 組合已經形成了「一個完整能力」
比如:
搜尋輸入 + 防抖 + 請求 + 取消舊請求
彈窗開關 + ESC 關閉 + 點擊外部關閉
表單值 + 校驗 + 提交 + 重置
這時候即使暫時只有一個元件用,也值得抽。 因為你不是為了「複用程式碼行數」,而是在複用一塊完整的互動語意。
3. 你想把元件從「業務控制器」還原成「UI 描述」
這類是我覺得最值得抽的情況。
很多元件一開始是這樣的:
上面一排
useState中間三段
useEffect下面五個 handler
最後才是 JSX
讀起來像一個微型控制器,而不是一個視圖。 這時候抽 Hook,不是為了炫技,而是為了讓元件重新變回「展示層」。
二、自訂 Hook 抽的不是程式碼,而是「問題邊界」
這是整篇最重要的一句。
很多人抽 Hook 時,容易按「技術點」拆:
一個 Hook 管 state
一個 Hook 管 effect
一個 Hook 管 callback
結果拆出來一堆 useA、useB、useC,誰都看不懂。
更好的方式是按「問題邊界」拆,也就是按業務上要解決的那件事拆。
比如你做一個搜尋面板,不要拆成:
useKeywordStateuseDebounceuseSearchEffect
而更應該優先思考:
這段邏輯整體上是不是「搜尋能力」?
這個能力對外最重要的輸入和輸出是什麼?
於是你就得到一個更語意化的介面:
const {
query,
setQuery,
results,
loading,
error,
} = useSearchUsers();這樣一來,呼叫方不需要知道內部到底用了 useEffect、useRef 還是 useCallback。
它只關心:這個 Hook 提供了一個「使用者搜尋能力」。
所以設計自訂 Hook 時,優先回答這兩個問題:
這個 Hook 在解決什麼完整問題?
呼叫它的元件,最少需要知道什麼?
三、一個壞例子:只是把元件程式碼搬走,不叫設計
先看一段常見的「抽了但沒完全抽」的程式碼。
元件裡原來的邏輯
function SearchPanel() {
const [query, setQuery] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query) {
setUsers([]);
return;
}
let ignore = false;
setLoading(true);
fetch(`/api/users?q=${query}`)
.then(res => res.json())
.then(data => {
if (!ignore) setUsers(data);
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [query]);
return ...
}很多人會直接抽成:
function useSearchPanelLogic() {
const [query, setQuery] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
...
}, [query]);
return { query, setQuery, users, loading };
}這不算錯,但也談不上「設計」。 因為它只是把原元件邏輯整塊搬到另一個檔案,名字換成了 Hook。
問題在於:
useSearchPanelLogic這個名字太貼近頁面,不是能力名。如果以後別的地方也要「搜尋使用者」,這個 Hook 可能並不通用。
它把「頁面」和「能力」綁在一起了。
更好的做法,是抽成一個語意更清楚的 Hook:
function useUserSearch(initialQuery = '') {
const [query, setQuery] = useState(initialQuery);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!query) {
setUsers([]);
return;
}
let ignore = false;
setLoading(true);
setError(null);
fetch(`/api/users?q=${query}`)
.then(res => res.json())
.then(data => {
if (!ignore) setUsers(data);
})
.catch(err => {
if (!ignore) setError(err);
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [query]);
return {
query,
setQuery,
users,
loading,
error,
};
}這時候它已經不是「頁面邏輯搬家」,而是一個真正有邊界的狀態能力模組了。
四、設計自訂 Hook 時,輸入輸出最好保持「少而穩」
自訂 Hook 最容易失控的地方,是回傳值越來越多。
比如:
const {
query,
setQuery,
users,
setUsers,
loading,
setLoading,
error,
setError,
refetch,
reset,
inputRef,
} = useUserSearch();這類 API 一看就有點不對勁。 原因不是「值太多」本身,而是它把內部狀態管理權也暴露出去了。
一個 Hook 設計得越成熟,對外暴露的東西通常越像兩類:
狀態
動作
比如:
const {
query,
users,
loading,
error,
setQuery,
refetch,
reset,
} = useUserSearch();這裡就很清楚:
query/users/loading/error是狀態。setQuery/refetch/reset是動作。
而像 setUsers、setLoading、setError 這種,一般都屬於 Hook 內部實作,不應該隨便暴露給呼叫者。
否則這個 Hook 就失去了自己的「約束力」,外部可以隨意把它狀態改亂。
所以有個很實用的設計原則: > Hook 的回傳值,優先暴露「可讀狀態」和「語意動作」,少暴露內部 setter。
這和元件封裝是一個道理。
五、把前幾篇講過的規則,真正沉澱進 Hook 裡
自訂 Hook 最有價值的地方,不是「複用程式碼」,而是把前面幾篇的最佳實踐,沉澱成團隊可複用的預設寫法。
也就是說,一個好的自訂 Hook,應該天然具備這些特徵:
不濫用 effect。
不同步派生狀態。
處理好閉包和依賴陣列。
只在必要時使用 ref 和 callback。
比如我們前面講過「防抖搜尋」,如果每個元件都自己手寫一遍:
有的人會忘記清理 timer;
有的人會寫出 stale closure;
有的人會把中間狀態同步成一團。
這時候把它封成 Hook,其實是在統一一套正確做法:
function useDebouncedValue<T>(value: T, delay: number) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const id = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(id);
}, [value, delay]);
return debouncedValue;
}然後業務元件就可以寫成:
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 400);這樣,呼叫者根本不需要重新思考防抖 effect 的邊界問題。 這就是自訂 Hook 的真正價值:把判斷成本前置一次,後面的人只管用。
六、3 個最值得在專案裡沉澱的基礎 Hook
這一節不追求花俏,講三個最容易落地、也最能體現設計感的 Hook。
1. usePrevious
function usePrevious<T>(value: T) {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}這個 Hook 很適合做對比邏輯,比如:
判斷值是否剛剛變化
做簡單動畫觸發
對比前後 props
它的意義不是程式碼複用本身,而是把「前一個值」的語意提煉出來。
2. useDebouncedValue
function useDebouncedValue<T>(value: T, delay: number) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const id = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(id);
}, [value, delay]);
return debouncedValue;
}這個 Hook 非常適合:
搜尋框
表單連動
頻繁輸入後的延遲請求
它把「防抖的時間語意」從元件裡抽掉了。
3. useEventCallback / latest-callback 模式
function useEventCallback<T extends (...args: any[]) => any>(fn: T) {
const fnRef = useRef(fn);
useEffect(() => {
fnRef.current = fn;
}, [fn]);
return useCallback((...args: Parameters<T>) => {
return fnRef.current(...args);
}, []);
}這個 Hook 的場景是:
你想拿到一個穩定函式參考;
它內部始終執行最新邏輯。
這類模式特別適合第三方事件系統、定時器工具、訂閱橋接。
不過這裡也要提醒一句:
它是工具模式,不是業務元件預設寫法。
普通場景老實寫依賴,往往更簡單。
七、什麼時候不該抽自訂 Hook?
這一節很重要。
不是元件裡出現了兩個 useState,就必須抽 Hook。
下面幾種情況,通常不值得抽:
1. 邏輯非常短,而且只在當前元件裡出現一次
比如:
const [open, setOpen] = useState(false);這種抽成 useModalOpen() 反而畫蛇添足。
因為它沒有形成一個真正的能力邊界,只是把簡單邏輯包了一層名字。
2. 抽出去之後,介面比原元件還難理解
如果一個 Hook 需要:
8 個參數
12 個回傳值
一堆 options 配置
那你大概率不是在抽象,而是在把複雜度隱藏起來。 複雜度沒有消失,只是換了個地方。
3. 邏輯和頁面結構強綁定,暫時看不出穩定邊界
有些東西現在就只屬於這個頁面,而且你還沒摸清它最終會怎麼演化。 這時候寧可先留在元件裡,也別急著抽一個半成品 Hook。
抽 Hook 的時機,通常是「邊界已經浮現」,不是「程式碼剛一變長就趕緊拆」。
八、一個完整重構範例:從頁面控制器到自訂 Hook
先看一個比較常見的頁面程式碼:
function UserSearchPage() {
const [query, setQuery] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const debouncedQuery = useDebouncedValue(query, 400);
useEffect(() => {
if (!debouncedQuery) {
setUsers([]);
return;
}
let ignore = false;
setLoading(true);
setError(null);
fetch(`/api/users?q=${debouncedQuery}`)
.then(res => res.json())
.then(data => {
if (!ignore) setUsers(data);
})
.catch(err => {
if (!ignore) setError(err);
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [debouncedQuery]);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
{loading && <div>Loading...</div>}
{error && <div>{error.message}</div>}
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}這段還算清楚,但隨著需求變多,很快就會變厚。 這時候可以抽成:
function useUserSearch(delay = 400) {
const [query, setQuery] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const debouncedQuery = useDebouncedValue(query, delay);
useEffect(() => {
if (!debouncedQuery) {
setUsers([]);
return;
}
let ignore = false;
setLoading(true);
setError(null);
fetch(`/api/users?q=${debouncedQuery}`)
.then(res => res.json())
.then(data => {
if (!ignore) setUsers(data);
})
.catch(err => {
if (!ignore) setError(err);
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [debouncedQuery]);
const reset = useCallback(() => {
setQuery('');
setUsers([]);
setError(null);
setLoading(false);
}, []);
return {
query,
setQuery,
users,
loading,
error,
reset,
};
}然後頁面變成:
function UserSearchPage() {
const {
query,
setQuery,
users,
loading,
error,
reset,
} = useUserSearch();
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
<button onClick={reset}>清空</button>
{loading && <div>Loading...</div>}
{error && <div>{error.message}</div>}
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}這樣改完最大的好處不是「程式碼少了」,而是頁面終於重新像頁面了:
它負責展示。
useUserSearch負責狀態能力。
這才是一個元件 + Hook 的自然分工。
九、這一篇可以帶走的結論
自訂 Hook 的目標不是單純複用程式碼,而是複用一段狀態邏輯的邊界和語意。
該抽 Hook 的時機,通常是邏輯已經形成一個完整能力,或者你想把元件重新還原成展示層。
抽 Hook 時要按「問題邊界」拆,而不是按
state/effect/callback這種技術點拆。Hook 對外優先暴露「狀態 + 動作」,少暴露內部 setter。
一個好的自訂 Hook,應該把前面幾篇講過的正確心智模型沉澱進去,而不是把複雜度原樣搬家。