2026年8月21日
React useTimeout Hook:宣告式 setTimeout 與自動清理 (2026)
這是一個「已複製!」按鈕。每個程式碼庫裡都有一個,而這個版本有三個 bug:
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
setTimeout(() => setCopied(false), 2000);
}, [copied]);
return (
<button onClick={() => { navigator.clipboard.writeText(text); setCopied(true); }}>
{copied ? "已複製!" : "複製"}
</button>
);
}
它從不清除定時器,所以在倒數途中卸載會留下一個指向已死組件的回調。它在每次 copied 變化時重新掛載,而不是乾淨地重啟。而在 React 18 的 StrictMode 下,effect 在掛載時會跑兩次,於是你本來只想要一個定時器,卻拿到了兩個。補上漏掉的 clearTimeout 能修掉洩漏,但修不掉問題的形狀:定時器的生命週期現在被綁進了依賴陣列,而你依然沒辦法在點擊事件裡取消它、按需重啟它,或者問一句「它還在跑嗎?」
setTimeout 是一個「射後不理」的瀏覽器原語。React 組件可不是射後不理的——它們會卸載、會重新渲染、會改變主意。@reactuses/core 裡的 useTimeout 與 useTimeoutFn 把這道鴻溝補上了:交給你的不是一個需要小心伺候的數字,而是一份狀態加兩個控制函式。這篇文章講它們底層到底做了什麼、那個所有人都會踩的行為(延遲是依賴,回調不是)、一個會悄悄汙染你參數的 start() 陷阱,以及值得直接抄走的幾個模式。
快速開始
npm install @reactuses/core
import { useTimeoutFn } from "@reactuses/core";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const [, startReset] = useTimeoutFn(() => setCopied(false), 2000, {
immediate: false,
});
return (
<button
onClick={async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
startReset();
}}
>
{copied ? "已複製!" : "複製"}
</button>
);
}
沒有 effect,沒有依賴陣列,沒有需要記著寫的清理。定時器由一次點擊、而不是由一次渲染來啟動;卸載時自動清除;在提示還掛著的時候再點一次「複製」,會重新開始這兩秒,而不是在第一個定時器上再疊一個。
兩個 Hook,一台引擎
兩個 hook 回傳的是同一個三元組——函式庫把它叫做 Stoppable:
type Stoppable = [isPending: boolean, start: Fn, cancel: Fn];
它們的差別只在於:時間到了之後會發生什麼。
useTimeoutFn(cb, ms, options?) 執行你的回調。當這個「到期」本身有事要做時用它——關掉 toast、重設旗標、送一個埋點。
useTimeout(ms?, options?) 不跑你的任何回調。它把 isPending 從 true 翻成 false 並觸發一次重新渲染。當這個「到期」本身就是狀態時用它——「300ms 過了沒?」就是全部的問題。
useTimeout 字面上就是把回調那個位置讓給「強制重新渲染」的 useTimeoutFn:
export const useTimeout: UseTimeout = (ms = 0, options = {}) => {
const update = useUpdate();
return useTimeoutFn(update, ms, options);
};
這個 useUpdate 是一個兩行的 useReducer,對一百萬取模遞增計數器——就是那個「不發明假 state 也能強制重新渲染」的標準技巧,取模是為了讓長壽命的組件不會一路飄向 Number.MAX_SAFE_INTEGER。它保證了到期那一刻一定會有一次渲染,即使在光靠 isPending 不足以觸發渲染的情況下也是如此。正是這一點,讓 useTimeout 可以當成一個純粹的「N 毫秒後重新渲染我」原語來用——例如你要重新讀一個並不是 React state 的值時。
預設兩者都在掛載時啟動。傳 { immediate: false },在你自己呼叫 start() 之前什麼都不會發生。
它到底做了什麼
實作大約二十行,而每一行都在回答開頭那個範例裡的某個 bug:
export const useTimeoutFn = (cb, interval, options = {}) => {
const { immediate = true } = options;
const [pending, setPending] = useState(() => immediate);
const savedCallback = useLatest(cb);
const timer = useRef<ReturnType<typeof setTimeout>>();
const stop = useEvent(() => {
setPending(false);
if (timer.current) clearTimeout(timer.current);
});
const start = useEvent((...args: unknown[]) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => {
setPending(false);
savedCallback.current(...args);
}, interval);
setPending(true);
});
useEffect(() => {
if (immediate) start();
return stop;
}, [stop, immediate, interval, start]);
return [pending, start, stop];
};
這裡塞進了五個決策,每一個都值得知道,因為每一個之後都會出現在你自己的程式碼裡。
回調住在 ref 裡,不在依賴裡。 useLatest 在每次渲染提交後把 savedCallback.current 指向最新的那個函式,定時器透過它來呼叫。所以到期時執行的閉包是你最近一次渲染裡的那個——過期閉包的 bug 消失了——但換掉回調不會重啟倒數。一個 5 秒的定時器跑到第 4 秒時還剩 1 秒,即使它將要呼叫的那個函式在這期間已經被重新建立了十次。這是正確的行為,倉庫裡有測試覆蓋,但對於「一個長得像 useEffect 的 hook,輸入變了就該重跑」這種預期來說,會讓人意外。
延遲確實在依賴裡。 interval 位在依賴陣列中,所以改動它會拆掉當前的定時器、從零開始一個新的。這是刻意的,通常也正是你想要的——但請看下面的坑,因為在渲染裡現算的延遲,是造出一個永遠跑不完的倒數的最快方式。
start 與 stop 的參考永不改變。 useEvent 把兩者都包進一個空依賴的 useCallback 並轉發給 ref,所以你在第 1 次渲染拿到的函式,和第 500 次渲染拿到的是同一個參考。你可以把它們放進依賴陣列、傳給被 memo 的子組件、或者塞進 context,都不會引發常見的抖動。
start() 先清後設。 在定時器已經在跑的時候呼叫它不會疊加——而是取消並重啟。這就是「再點一次複製」表現正常的原因;也意味著在每次按鍵時反覆呼叫 start(),你就白得了一份防抖語義(不過 useDebounceFn 把意圖說得更清楚)。
pending 是被預先種下的,不是閃出來的。 useState(() => immediate) 意味著當 immediate 打開時,第一次渲染讀到的就已經是 true——掛載時沒有 false → true 的閃爍,也沒有浪費掉的一次渲染。而且因為 immediate 只是一個普通選項、伺服器端與客戶端取值一致,這個預設值在兩邊完全相同。這個 hook 裡沒有任何東西碰 window、document 或 Date,所以它不需要守衛就能在伺服器端渲染,也不會出現 hydration 不匹配。
effect 的清理函式就是 stop 本身,這就是洩漏的修法:卸載時一定清除定時器,無論它當時處於什麼狀態。
值得抄走的模式
延遲出現的 loading
useTimeout 最好的用途。一個出現 80ms 就消失的 spinner 讀起來只是一次閃爍——比完全不顯示還糟。解法是只在載入真的很慢時才顯示,而這恰好就是「300ms 過了沒?」:
function UserList() {
const { data, isLoading } = useUsers();
const [tooSoon] = useTimeout(300);
if (isLoading) return tooSoon ? null : <Spinner />;
return <List items={data} />;
}
tooSoon 初始為 true,掛載 300ms 後翻成 false。快速返回的請求在這段空隙裡什麼都不渲染;慢的才會拿到 spinner。一行,無 state,無 effect。
自動消失 + 滑鼠懸停暫停
元組裡的 cancel 與 start 讓這件事變得很簡單——手寫版本需要一個 ref 和兩個 effect:
function Toast({ message, onDismiss }: { message: string; onDismiss: () => void }) {
const [, start, cancel] = useTimeoutFn(onDismiss, 5000);
return (
<div role="status" onMouseEnter={cancel} onMouseLeave={() => start()}>
{message}
</div>
);
}
注意 onMouseLeave 上的 () => start()。這不是風格偏好——見下面的坑。
冷卻按鈕
function ResendCodeButton({ onResend }: { onResend: () => void }) {
const [cooling, startCooldown] = useTimeout(30_000, { immediate: false });
return (
<button
disabled={cooling}
onClick={() => { onResend(); startCooldown(); }}
>
{cooling ? "驗證碼已送出,請稍候" : "重新傳送驗證碼"}
</button>
);
}
immediate: false 是關鍵:按鈕在掛載時是可用的,只有被用過一次之後才進入冷卻。如果你想渲染剩餘秒數而不是一個布林值,那是另一個 hook 的工作——useCountDown 會幫你倒數並把數字交給你。
把控制權交還給瀏覽器
不帶參數的 useTimeout() 預設 ms = 0,它依然會推遲到一個 macrotask——在繪製之後、在待處理的 microtask 之後。偶爾這正是你想要的那個逃生口:「先讓瀏覽器把這一幀畫出來,我再做那件耗時的事」,而且比一個 requestIdleCallback polyfill 更好推理。如果你要的是每一幀執行而不是執行一次,用 useRafFn。
值得知道的坑
-
start會把參數轉發給你的回調。 這是一個真實的特性——start(userId)會把userId直接傳給定時器回調——同時也是一個真實的陷阱,只要呼叫方是 DOM 事件處理器。onMouseLeave={start}會把 React 的合成MouseEvent直接塞進你的onDismiss(...)。如果那個回調是onDismiss(id?: string),你就用一個事件物件當 id 關掉了一個 toast,而 TypeScript 不會攔你,因為start的型別是Fn。包一層:onMouseLeave={() => start()}。onClick、onBlur以及任何會傳事件的地方,同理。 -
延遲一變,倒數就重啟——每一次都是。
interval是依賴,所以下面這個永遠不會觸發:// 有 bug:每次渲染都是新的延遲,定時器被無限重啟 useTimeoutFn(onDone, Math.max(0, deadline - Date.now()));任何按渲染重算的延遲,都會在它跑完之前把時鐘歸零。傳一個穩定的數字,或者把它 memo 起來。反過來這個特性也有用:當延遲是真的變了——使用者在「3 秒後消失 / 10 秒後消失 / 不消失」之間切換——重啟正是對的。
-
回調變了不會重啟它。 上一條的鏡像,同樣值得記進肌肉記憶。你的回調永遠是最新的那個,但它的到期時刻是
start()執行時定下的那個。 -
cancel()會把isPending設為false。 它是停止,不是暫停——沒有「用剩餘時間繼續」這回事。cancel()之後再start(),走的是一個完整的新延遲。如果你需要真正的暫停/恢復語義,得自己記錄已經過去的時間,並把剩餘時間當作新的延遲傳進去。 -
卸載之後
isPending會凍結在最後一次渲染的值上。 清理函式呼叫了stop(),它清除了定時器並呼叫setPending(false)——但這個狀態更新落在一個已卸載的組件上,React 會丟棄它。如果你在測試裡快照了這個元組、在unmount()之後再讀,isPending依然會是true。這不是洩漏,也不會有警告;定時器是真的被清掉了。 -
StrictMode 會掛兩次,但結果會收斂。 在 React 18 的開發模式下,掛載 effect 會執行、清理、再執行一次,所以在 dev 裡你會看到兩次
setTimeout呼叫。永遠不會重複觸發——stop清掉了第一個,start在排新的之前又清了一次——但倒數實際上是從第二次執行開始算的。實際使用中這是次毫秒級的差別;但在一個用假定時器精確推進時間的測試裡,這個差別是會咬人的。 -
immediate在掛載時被讀取,同時也是依賴。 在後續某次渲染裡把immediate從false翻成true,會啟動定時器,因為它在 effect 的依賴裡。用切換它的方式來宣告式地武裝一個定時器是完全合理的做法——只是別驚訝於它並不是惰性的。
什麼時候不該用它
這兩個 hook 是對單一 setTimeout 的一層薄而誠實的封裝。當你的問題有專門的名字時,對應的 hook 已經處理好了那些你否則要重新踩一遍的邊界情況:
- 按週期重複執行 → 用
useInterval,而不是讓一個 timeout 自己重新武裝自己。自排程的 timeout 會漂移,而且取消起來極其難受。 - 「等使用者停止輸入」 → 回調用
useDebounceFn,值用useDebounce。你可以靠每次按鍵呼叫start()來拼出來,但專用 hook 一眼就能讀懂。 - 「每 N 毫秒最多一次」 →
useThrottleFn/useThrottle。timeout 是限流的錯誤原語;第一次呼叫就該立刻通過。 - 看得見的倒數 →
useCountDown。用單一 timeout 渲染「4… 3… 2…」意味著你要自己跑一個 tick 迴圈。 - 「使用者是不是不動了?」 →
useIdle,它已經監聽了正確的那組活動事件。 - 逐幀動畫 →
useRafFn。setTimeout不與合成器對齊,而且在背景分頁裡還會繼續跑。 - 只是想在卸載時清理 →
useUnmount。根本不需要定時器。
重點回顧
- 在
useEffect裡寫setTimeout,逼著你同時手動管四件事:清理、依賴陣列、過期閉包、以及缺失的控制能力。對三個錯一個,是常態。 useTimeoutFn回傳[isPending, start, cancel],並且天生在卸載時清除。useTimeout是同一台引擎,只是把回調那一格用來觸發重新渲染——適合「到期本身就是你關心的狀態」的場景。- 延遲是依賴,回調不是——改延遲會重啟倒數,改回調只會悄悄換掉將要執行的函式。兩者都是刻意設計;分清哪個是哪個能省下一個下午。
start會轉發參數,所以永遠不要把它直接傳給 DOM 事件處理器。寫onMouseLeave={() => start()},不要寫onMouseLeave={start}。start與cancel的參考永久穩定,isPending被預先種下所以掛載時不會閃,而且 hook 裡沒有任何東西碰瀏覽器全域物件——它能原封不動地在伺服器端渲染。
useTimeout、useTimeoutFn、useInterval,以及另外 110+ 個 SSR 安全、TypeScript 優先的 hook 都在 @reactuses/core 裡——一次安裝,支援 tree-shaking,沒有需要伺候的依賴。
npm install @reactuses/core