2026年5月18日

React 裡不用 setTimeout 的計時器寫法
、useInterval、useCountDown 和 useRafFn

計時器是那種每個 React 開發者頭十次都會自己手寫、其中至少六次寫錯的東西。模式看起來很簡單

useEffectsetTimeout,回傳一個清理函式,提交。然後程式碼評審發現了過期閉包。然後 bug 單進來了,因為 delay 是在掛載時從 props 讀的,而不是當前渲染裡的。然後有人注意到在慢頁面上元件已經卸載了,interval 還在跑。然後你發現 setInterval 每個週期都會漂移一點,你的倒數計時跑一分鐘之後差了 800ms。然後效能稽核指出有個動畫迴圈,沒人記得在標籤頁隱藏時暫停。

這些 bug 沒一個有意思。它們都是同一類 bug

,壞的是 React 的接入方式。ReactUse 提供了六個小 hook,把這個接入做掉,讓你只寫計時器邏輯本身:useTimeoutuseTimeoutFnuseIntervaluseCountDownuseRafFnuseRafState

這篇文章逐個走一遍——底層原語是什麼、在 React 裡手寫版長什麼樣、hook 藏了什麼 bug、它真正該出現在你程式碼的哪裡。看完你應該知道什麼場景該掏哪個計時器 hook,以及為什麼。

一段程式碼先把問題說清楚

在引入任何 hook 之前,幾乎每個 React 程式碼庫都至少寫過一次這個:

function Toast({ message, durationMs }: { message: string; durationMs: number }) {
  const [visible, setVisible] = useState(true);

  useEffect(() => {
    const id = setTimeout(() => setVisible(false), durationMs);
    return () => clearTimeout(id);
  }, [durationMs]);

  return visible ? <div className="toast">{message}</div> : null;
}

這段大體上是對的。bug 在於缺了什麼:

  1. 依賴陣列讓 effect 在 durationMs 每次變化時重新執行——所以父元件在過程中更新了 prop,會把計時器從零重啟,而不是讓它跑完。
  2. 沒辦法從外面取消計時器(比如一個「關閉」按鈕),除非把 visible 狀態提上去。
  3. 沒辦法讀取計時器還在不在等待——這在測試裡、埋點裡、顯示一個「2 秒後消失……」的標籤裡都有用。
  4. 清理在卸載時跑,這是對的,但它也會在 durationMs 變化導致的每一次重新渲染時跑,這通常不是你想要的。

這四個都能用 useRef 拼出來,但是那種沒人願意寫第二遍的拼接程式碼。useTimeoutFn 存在的意義就是這個。

1. useTimeoutFn——正確的 setTimeout

useTimeoutFn(callback, interval, options?)interval 毫秒後排程 callback,回傳 [isPending, cancel, restart]。它幹了三件 naive 版沒幹的事:

  • 永遠呼叫最新的 callback——即使你不把它列在 deps 裡,也不會有過期閉包。
  • cancel() 讓父元件或兄弟元件不用卸載就能停掉計時器。
  • restart() 讓你不用改 key、不用重新掛載就能重置時鐘。

重寫 Toast:

import { useTimeoutFn } from "@reactuses/core";

function Toast({ message, durationMs, onClose }: {
  message: string;
  durationMs: number;
  onClose: () => void;
}) {
  const [isPending, cancel, restart] = useTimeoutFn(onClose, durationMs);

  return (
    <div className="toast" onMouseEnter={cancel} onMouseLeave={() => restart()}>
      {message}
      {isPending && <span className="fade-bar" />}
    </div>
  );
}

注意消失了的東西

useEffect、沒有 setTimeout、沒有 clearTimeout、沒有 useRef、沒有 useCallback。hover 行為——使用者在看 toast 時暫停自動消失——一行程式碼。isPending 旗標驅動那個淡出條,不需要額外的狀態。

immediate 選項(預設 true)控制計時器是不是在掛載時啟動。設為 false 就是「按需觸發」:

const [, , scheduleSave] = useTimeoutFn(saveDraft, 2000, { immediate: false });

return <textarea onChange={(e) => { setText(e.target.value); scheduleSave(); }} />;

每次按鍵都把 save 往後推 2 秒。這是構造「使用者停止輸入 2 秒後儲存」防抖的一種方式,不過對這種特定模式 useDebounceFn 通常更乾淨。

2. useTimeout——只想 N 毫秒後重新渲染

useTimeout(ms, options?)useTimeoutFn 是同一個東西,只不過回呼是元件自己的重新渲染。當你只想讓一段 UI 在延遲後「出現」,又不想存一個布林時用它。

import { useTimeout } from "@reactuses/core";

function DelayedSpinner({ delayMs = 250 }: { delayMs?: number }) {
  const [isPending] = useTimeout(delayMs);
  return isPending ? null : <Spinner />;
}

場景是「不要為低於 250ms 的載入顯示 spinner」。如果父元件在 100ms 內完成載入,spinner 永遠不會被看見——沒有閃爍。如果更長,spinner 出現。沒有狀態、沒有 effect、沒有布林。

回傳形狀跟 useTimeoutFn 一樣,所以你如果想打斷重新渲染,cancelrestart 也在那。實際中讀取的用法佔多數。

3. useInterval——真的能暫停的 setInterval

useInterval(callback, delay, options?)delay 毫秒跑一次 callback。回傳值是 { isActive, pause, resume },不是一個元組——useInterval 是圍繞暫停/恢復這件事建的,因為這是所有人都需要、但所有人用原生 setInterval 都實作不對的操作。

setInterval 在 React 裡最常見的 bug 不是清理——現代 linter 都能抓到——而是null 來停掉計時器。用 useInterval,這個模式直接可用:

import { useInterval } from "@reactuses/core";

function Polling({ active, onTick }: { active: boolean; onTick: () => void }) {
  useInterval(onTick, active ? 5000 : null);
  return null;
}

active 翻成 false 時,delay 變成 null,interval 被清掉。翻回來時,interval 以新的 delay 重啟。沒有 useEffect、沒有 ref 雜耍、沒有「我是不是在 active 的正確取值上清理了」那種擔心。

如果你傾向於從 hook 外面顯式 pause/resume(比如使用者離線時暫停輪詢),用 controls: true 選項把控制權拿走:

const { isActive, pause, resume } = useInterval(refresh, 5000, {
  controls: true,
  immediate: true,
});

useEffect(() => {
  const onVisibilityChange = () =>
    document.hidden ? pause() : resume();
  document.addEventListener("visibilitychange", onVisibilityChange);
  return () => document.removeEventListener("visibilitychange", onVisibilityChange);
}, [pause, resume]);

光這一段就修了一類在正式環境裡到處都是的 bug

,輪詢還在全速跑,燒電池,燒速率限制的額度。

為什麼不用 setInterval + 漂移修正?

setInterval 不保證兩次呼叫之間是精確的 delay——頁面被節流時(背景標籤頁、電量低、Chrome 的 “intensive throttling”)瀏覽器可能延遲或合併回呼。對一個輪詢迴圈,這沒事。對一個時鐘顯示,這是肉眼可見的錯

60 個「每秒一次」的 tick 之後,顯示的時間可能比真實牆鐘慢一兩秒。

對時鐘這種東西,不要用 useInterval 驅動顯示值。用 useInterval 排程重新渲染,渲染裡讀 Date.now():

function Clock() {
  const [, force] = useState(0);
  useInterval(() => force((n) => n + 1), 1000);
  return <span>{new Date().toLocaleTimeString()}</span>;
}

interval 可以漂,顯示的時間在每次渲染時新鮮讀出。漂移變成排程問題,不再是正確性問題。

4. useCountDown——小時分鐘秒,不用自己算日期

倒數計時是帶額外責任的 interval

、格式化顯示、歸零時觸發回呼、之後停掉計時器。元件層面的實作大概是 30 行程式碼,每個人都至少寫過一次。

useCountDown(time, format?, callback?) 回傳 [小時, 分鐘, 秒] 三個字串(零填充)的元組,並把上面這些事都做了:

import { useCountDown } from "@reactuses/core";

function OtpResend({ onExpire }: { onExpire: () => void }) {
  const [h, m, s] = useCountDown(60, undefined, onExpire);
  const expired = h === "00" && m === "00" && s === "00";

  return expired
    ? <button onClick={() => /* 再請求一次 */ undefined}>重新傳送驗證碼</button>
    : <span>{m}:{s} 後可重發</span>;
}

hook 擁有 interval、剩餘時間狀態和回呼分派。元件擁有渲染決策。如果你想要不同的格式(比如 X 分 Y 秒 或者純秒數),傳一個 format 函式,它接受剩餘秒數回傳三個字串——hook 在每個 tick 上呼叫它,回傳你給的東西。

useCountDown 在時間歸零後會鉗到 ["00", "00", "00"],且拒絕溢出超過 99 小時,所以你不用在檢視層防禦奇怪的輸入。

5. useRafFn——需要 60fps,而不是「大概每秒一次」

setInterval(fn, 16) 是「每幀跑一次」的錯誤寫法。瀏覽器已經有「每幀一次、跟顯示更新同步、標籤頁隱藏時跳過」的原語——requestAnimationFrameuseRafFn(callback, initiallyActive?) 是它的 React 封裝。

回呼收到當前的高解析度時間戳(就是 requestAnimationFrame 傳給回呼的那個值),hook 回傳 [stop, start, isActive]

一個 canvas 粒子模擬、一段流暢的捲動位置讀取、一個 CSS 變數驅動的動畫——任何需要每幀更新的東西都該用 useRafFn:

import { useRafFn } from "@reactuses/core";
import { useRef } from "react";

function FollowCursor() {
  const ref = useRef<HTMLDivElement>(null);
  const target = useRef({ x: 0, y: 0 });
  const current = useRef({ x: 0, y: 0 });

  useEffect(() => {
    const onMove = (e: MouseEvent) => { target.current = { x: e.clientX, y: e.clientY }; };
    window.addEventListener("mousemove", onMove);
    return () => window.removeEventListener("mousemove", onMove);
  }, []);

  useRafFn(() => {
    // 每幀朝目標做一次類似彈簧的 lerp
    current.current.x += (target.current.x - current.current.x) * 0.15;
    current.current.y += (target.current.y - current.current.y) * 0.15;
    if (ref.current) {
      ref.current.style.transform = `translate3d(${current.current.x}px, ${current.current.y}px, 0)`;
    }
  });

  return <div ref={ref} className="follower" />;
}

注意兩件事。第一,動畫沒有呼叫 setState。直接往 ref.current.style 推,把工作放在 React 的渲染週期之外——這是在一個非平凡頁面上拿到真正 60fps 的唯一方式。第二,標籤頁隱藏時,瀏覽器會自動停掉 requestAnimationFrame——沒有 useInterval 風格的節流斷崖,普通情況也不用手寫暫停邏輯。

如果你確實想要手動控制(比如只在面板打開時動畫),第二個引數傳 false,在你的 effect 裡呼叫 start()/stop()

6. useRafState——你真的要重新渲染時的動畫的批次處理 state

useRafFn 在你能直接改 DOM 時很棒。有時候你不能——你必須把新值推進 React state,因為它驅動了一棵 JSX 子樹。naive 版長這樣:

const [pos, setPos] = useState({ x: 0, y: 0 });
// ……滑鼠移動時每秒 60 次 setPos

能跑,但每次 setPos 都觸發渲染。如果游標比 60Hz 更快地觸發 mousemove(有些瀏覽器就是),你會得到比幀還多的渲染。useRafState 透過把 state 更新批次到 requestAnimationFrame 解決這個問題——即使 setState 之間被呼叫了很多次,每幀最多渲染一次。

import { useRafState } from "@reactuses/core";

function CursorBadge() {
  const [pos, setPos] = useRafState({ x: 0, y: 0 });

  useEventListener("mousemove", (e) => {
    setPos({ x: e.clientX, y: e.clientY });
  });

  return <div style={{ left: pos.x, top: pos.y }} className="badge" />;
}

不管 mousemove 觸發多少次,元件每秒最多重新渲染 60 次。它是 useState 的一行替換,只要更新源是高頻瀏覽器事件(滑鼠、捲動、resize),目標是 JSX。

事件那邊搭配 useEventListener;目標是 DOM 改動時改用 useRafFn

什麼時候用哪個

選擇不是偏好問題——每個 hook 對應一種特定形狀的問題:

你想要……
N 毫秒後跑一次回呼useTimeoutFn
N 毫秒後強制一次重新渲染useTimeout
每 N 毫秒跑一次回呼,帶 pause/resumeuseInterval
顯示 hh:mm
剩餘時間
useCountDown
每幀幹活,不動 React stateuseRafFn
每幀最多更新一次 React stateuseRafState
等使用者停止輸入useDebounceFn
把回呼速率壓到每 N 毫秒一次useThrottleFn

最後兩個——useDebounceFnuseThrottleFn——嚴格說不是計時器 hook,但它們是同一族的。我們在 React 裡的防抖 vs 節流 裡講過;一句話版本是「阻止高頻事件觸發得太頻繁」,而不是「把工作排程到未來」。

三個 hook 悄悄防住的錯誤

上面這些 hook 讓一些微妙的 bug 寫不出來。

錯誤 1
useState 初始化器裡 setTimeout

const [id] = useState(() => setTimeout(callback, 1000)); // 錯

這會排程一個在 Strict Mode 故意的雙重呼叫下活下來的計時器,而且沒清理。用 effect 和 ref 來「修」是好幾行。useTimeoutFn(callback, 1000) 是一行,在構造上就對雙重呼叫安全。

錯誤 2
interval 回呼裡讀 state

const [count, setCount] = useState(0);
useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(id);
}, []); // 永遠捕獲了 count=0——count 走 0, 1, 1, 1, 1...

這是 React 計時器 bug 裡被 Google 搜得最多的那個。在原生 React 裡的修法是函式式更新(setCount((c) => c + 1))或者 ref。在 useInterval 裡的修法是「它本來就對」——hook 在內部把最新回呼用 ref 路由了。

錯誤 3
60fps 上動畫 React state

const [x, setX] = useState(0);
useEffect(() => {
  const tick = () => { setX((v) => v + 1); requestAnimationFrame(tick); };
  requestAnimationFrame(tick);
}, []);

一個元件能跑。螢幕上十個,React 的渲染佇列開始掉幀,因為每個 setState 都觸發一次完整的協調。useRafFn 讓你不走 React 直接改 DOM;useRafState 在沒法改 DOM 時把渲染封到每幀一次。兩個都對;上面這個迴圈只是湊巧對了。

組裝起來
「標籤頁閒置刷新器」

收尾一個小但真實的元件——一個資料卡片,在標籤頁可見且使用者活躍時每 30 秒輪詢一次,並顯示到下一次刷新的倒數計時:

import { useInterval, useCountDown } from "@reactuses/core";
import { useState, useCallback } from "react";

function LiveStat({ fetchValue }: { fetchValue: () => Promise<number> }) {
  const [value, setValue] = useState<number | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [, m, s] = useCountDown(30);

  const refresh = useCallback(async () => {
    try {
      setValue(await fetchValue());
      setError(null);
    } catch (e) {
      setError(e instanceof Error ? e.message : "未知錯誤");
    }
  }, [fetchValue]);

  useInterval(refresh, 30_000, { immediate: true });

  return (
    <div className="card">
      <div className="value">{value ?? "—"}</div>
      <div className="footer">
        {error ? `錯誤:${error}` : `${m}:${s} 後重新整理`}
      </div>
    </div>
  );
}

useInterval 擁有輪詢節奏。useCountDown 擁有視覺計時器。兩個互相不知道對方;它們湊巧落在同一個數字上,因為是用同一個常數種下的。兩個 hook,沒有 useEffect、沒有 setTimeout、沒有 useRef

試試看

這篇裡每個 hook 在文件頁都有可跑的 demo。吸收 API 最快的方式是讀 demo、改一個 prop、看看壞在哪:

npm install @reactuses/core(或 pnpm add @reactuses/core)裝上,直接 import。沒有 provider、沒有設定、除了 React 16.8+ 之外沒有 peer dependency。完整的 hook 列表和這篇裡所有東西的原始碼在 reactuse.com

別再在 useEffect 裡寫 setTimeout 了。對的工具存在,而且更短。