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 裡的 useTimeoutuseTimeoutFn 把這道鴻溝補上了:交給你的不是一個需要小心伺候的數字,而是一份狀態加兩個控制函式。這篇文章講它們底層到底做了什麼、那個所有人都會踩的行為(延遲是依賴,回調不是)、一個會悄悄汙染你參數的 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?) 不跑你的任何回調。它把 isPendingtrue 翻成 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 位在依賴陣列中,所以改動它會拆掉當前的定時器、從零開始一個新的。這是刻意的,通常也正是你想要的——但請看下面的坑,因為在渲染裡現算的延遲,是造出一個永遠跑不完的倒數的最快方式。

startstop 的參考永不改變。 useEvent 把兩者都包進一個空依賴的 useCallback 並轉發給 ref,所以你在第 1 次渲染拿到的函式,和第 500 次渲染拿到的是同一個參考。你可以把它們放進依賴陣列、傳給被 memo 的子組件、或者塞進 context,都不會引發常見的抖動。

start() 先清後設。 在定時器已經在跑的時候呼叫它不會疊加——而是取消並重啟。這就是「再點一次複製」表現正常的原因;也意味著在每次按鍵時反覆呼叫 start(),你就白得了一份防抖語義(不過 useDebounceFn 把意圖說得更清楚)。

pending 是被預先種下的,不是閃出來的。 useState(() => immediate) 意味著當 immediate 打開時,第一次渲染讀到的就已經是 true——掛載時沒有 false → true 的閃爍,也沒有浪費掉的一次渲染。而且因為 immediate 只是一個普通選項、伺服器端與客戶端取值一致,這個預設值在兩邊完全相同。這個 hook 裡沒有任何東西碰 windowdocumentDate,所以它不需要守衛就能在伺服器端渲染,也不會出現 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。

自動消失 + 滑鼠懸停暫停

元組裡的 cancelstart 讓這件事變得很簡單——手寫版本需要一個 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()}onClickonBlur 以及任何會傳事件的地方,同理。

  • 延遲一變,倒數就重啟——每一次都是。 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 在掛載時被讀取,同時也是依賴。 在後續某次渲染裡把 immediatefalse 翻成 true啟動定時器,因為它在 effect 的依賴裡。用切換它的方式來宣告式地武裝一個定時器是完全合理的做法——只是別驚訝於它並不是惰性的。

什麼時候不該用它

這兩個 hook 是對單一 setTimeout 的一層薄而誠實的封裝。當你的問題有專門的名字時,對應的 hook 已經處理好了那些你否則要重新踩一遍的邊界情況:

  • 按週期重複執行 → 用 useInterval,而不是讓一個 timeout 自己重新武裝自己。自排程的 timeout 會漂移,而且取消起來極其難受。
  • 「等使用者停止輸入」 → 回調用 useDebounceFn,值用 useDebounce。你可以靠每次按鍵呼叫 start() 來拼出來,但專用 hook 一眼就能讀懂。
  • 「每 N 毫秒最多一次」useThrottleFn / useThrottle。timeout 是限流的錯誤原語;第一次呼叫就該立刻通過。
  • 看得見的倒數useCountDown。用單一 timeout 渲染「4… 3… 2…」意味著你要自己跑一個 tick 迴圈。
  • 「使用者是不是不動了?」useIdle,它已經監聽了正確的那組活動事件。
  • 逐幀動畫useRafFnsetTimeout 不與合成器對齊,而且在背景分頁裡還會繼續跑。
  • 只是想在卸載時清理useUnmount。根本不需要定時器。

重點回顧

  • useEffect 裡寫 setTimeout,逼著你同時手動管四件事:清理、依賴陣列、過期閉包、以及缺失的控制能力。對三個錯一個,是常態。
  • useTimeoutFn 回傳 [isPending, start, cancel],並且天生在卸載時清除。useTimeout 是同一台引擎,只是把回調那一格用來觸發重新渲染——適合「到期本身就是你關心的狀態」的場景。
  • 延遲是依賴,回調不是——改延遲會重啟倒數,改回調只會悄悄換掉將要執行的函式。兩者都是刻意設計;分清哪個是哪個能省下一個下午。
  • start 會轉發參數,所以永遠不要把它直接傳給 DOM 事件處理器。寫 onMouseLeave={() => start()},不要寫 onMouseLeave={start}
  • startcancel 的參考永久穩定,isPending 被預先種下所以掛載時不會閃,而且 hook 裡沒有任何東西碰瀏覽器全域物件——它能原封不動地在伺服器端渲染。

useTimeoutuseTimeoutFnuseInterval,以及另外 110+ 個 SSR 安全、TypeScript 優先的 hook 都在 @reactuses/core 裡——一次安裝,支援 tree-shaking,沒有需要伺候的依賴。

npm install @reactuses/core