2026年8月15日

React useInterval Hook:沒有過期閉包的 setInterval (2026)

每個 React 開發者都寫過一遍這個元件,而它從來不按預期工作:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

它顯示 01……然後永遠停在 1。interval 的回呼是在第一次渲染時建立的,那時 count0,而空依賴陣列意味著它再也看不到後續的渲染。setCount(0 + 1) 每秒跑一次,什麼都沒變。這是 React 定時器裡被搜尋最多的一個 bug,而人們找到的”修法”——把 count 加進依賴(現在 interval 每秒被拆掉重建一次)、用函式式更新(能用,直到回呼需要讀除了上一個 count 之外的任何東西)——都是在跟同一個底層錯配硬扛:setInterval 是命令式的,活在 React 渲染週期之外,而它想讀的所有東西都活在渲染週期之內。

Dan Abramov 2019 年那篇 Making setInterval Declarative with React Hooks 給了這個錯配一個真正的解法:一個 useInterval hook,把最新的回呼存在 ref 裡,絕不因為你的元件重新渲染就重啟定時器。@reactuses/core 裡的 useInterval 就是這個思路,外加真實應用裡你遲早會需要的那幾塊——null 暫停、pause() / resume() 控制、immediate 選項,以及能扛住 StrictMode 的清理。本文會講清楚它的原理、兩種暫停方式、動態輪詢間隔、後臺分頁問題,以及什麼時候該換另一個定時器 hook。

快速上手

npm install @reactuses/core
import { useInterval } from "@reactuses/core";
import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  useInterval(() => {
    setCount(count + 1); // 讀到的是當前的 count——不需要函式式更新
  }, 1000);

  return <h1>{count}</h1>;
}

這就是開頭那個壞掉的元件,把 useEffect + setInterval 換成 useInterval 就修好了。回呼可以直接讀任何 prop 或 state,定時器只建立一次、卸載時清除,也沒有會寫錯的依賴陣列。

簽名是 useInterval(callback, delay, options?)——delay 以毫秒計,傳 null 則暫停——回傳 { isActive, pause, resume },給你需要手動控制的場景用。

為什麼 setInterval 和 React 合不來

底下其實是三個獨立的問題,hook 對每一個的解法都不同:

  1. 過期閉包。 setInterval 一輩子只持有一個函式引用。這個函式閉包住了某一次渲染的 props 和 state。之後每次渲染都會建立新的閉包——而正在跑的 interval 永遠看不到。
  2. 一渲染就重啟。 顯而易見的修法是讓 effect 依賴回呼讀到的所有東西:useEffect(..., [count])。現在 interval 正確了,但每次變化都被清掉重建——計時每次歸零,依賴變得快的話,tick 可能一次都觸發不了。
  3. 生命週期。 你得在卸載時清除 interval,StrictMode 的開發環境二次掛載時再清一次,還有——會變成一個小狀態機的那部分——決定怎麼暫停它:第二個 state,包住 setInterval 的一個 if,以及現在得把暫停標誌也算進去的依賴。

三個問題都處理好之後,一個正確的手寫版本長這樣——一個存最新回呼的 ref、一個只以 delay 為依賴的 effect、用 null 表示暫停:

function useIntervalManual(callback: () => void, delay: number | null) {
  const savedCallback = useRef(callback);

  useLayoutEffect(() => {
    savedCallback.current = callback; // 永遠是最新一次渲染的閉包
  }, [callback]);

  useEffect(() => {
    if (delay === null) return;
    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

這基本就是 @reactuses/coreuseInterval 的核心——它用 useLatest 做那個 ref,再在上面加控制。這個設計天然帶來兩條性質,也是最該記住的兩條:

  • 改回調絕不重啟定時器。 你想怎麼重渲染就怎麼重渲染,傳內聯箭頭函式、讀任何 state——ref 被更新,interval 保持自己的節奏。
  • delay 會重啟。 delay 是唯一的依賴,所以 5000 → 1000 會清掉舊 interval 再起一個新的。這會重置相位:下一個 tick 距離變更提交的那一刻整整一個 delay。通常是對的(下面的退避就靠這個),偶爾會讓人意外——如果你以為進行中的那個 tick 會先跑完的話。

暫停:null vs pause() / resume()

停掉 interval 有兩種方式,選對了元件就簡單。

宣告式——把 null 當作 delay 傳進去。 當”它該不該跑”能從 state 或 props 推匯出來時,把它編碼進 delay 表示式,讓 hook 跟著走:

function LivePrice({ symbol, live }: { symbol: string; live: boolean }) {
  const [price, setPrice] = useState<number | null>(null);

  useInterval(
    async () => setPrice(await fetchPrice(symbol)),
    live ? 5000 : null, // false → 暫停,true → 每 5 秒輪詢
  );

  return <span>{price ?? "—"}</span>;
}

翻轉 live,interval 就清除或重啟。沒有 effect,沒有 ref,沒有額外的 state。

命令式——controls: true 加上回傳的控制代碼。 當啟停是一個使用者動作而不是派生條件(開始/停止按鈕、“這個彈窗開啟期間暫停”)時,退出自動啟動,自己來開:

function Stopwatch() {
  const [elapsed, setElapsed] = useState(0);
  const [running, setRunning] = useState(false);
  const startedAt = useRef(0);

  const { pause, resume } = useInterval(
    () => setElapsed(Date.now() - startedAt.current), // 讀時鐘,別數 tick
    100,
    { controls: true }, // 掛載時不啟動——等 resume()
  );

  const toggle = () => {
    if (running) {
      pause();
    } else {
      startedAt.current = Date.now() - elapsed;
      resume();
    }
    setRunning(!running);
  };

  return (
    <>
      <p>{(elapsed / 1000).toFixed(1)}s</p>
      <button onClick={toggle}>{running ? "暫停" : "開始"}</button>
    </>
  );
}

pauseresume 的引用是穩定的(放進依賴陣列和事件處理器都安全),而且即使在 controls 模式下,interval 依然會在卸載時清除——你不可能因為忘了而漏掉一個定時器。一句實話:回傳值裡的 isActive 是一個 refisActive.current),不是 state——它翻轉時不會觸發重渲染,所以上面的例子為按鈕文案單獨維護了 running state。

兩者也能混用:不開 controls 時,pause() 也能當臨時覆蓋用,下一次 delay 變化會自動恢復。

immediate:現在就跑一次,然後每 N 毫秒一次

setInterval 要等滿一個 delay 才第一次呼叫,對輪詢來說這幾乎從來不是你想要的——使用者得盯著空白螢幕五秒。immediate: true 會在 interval 啟動時同步執行一次回呼,然後照常按計劃走:

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

注意,“interval 啟動時”包括每一次 delay 變化——delay 的值每變一次,回呼就立刻跑一次、計劃重新開始。使用者調整重新整理頻率時這很順手,但對失敗驅動的退避來說恰恰是錯的(每次拉長 delay 都會當場再觸發一次呼叫),所以退避場景別開 immediate——見下一節。

真實場景裡的模式

帶退避的輪詢

因為 delay 就是個普通值,退避就只是 state。失敗時拉長間隔,成功時彈回來:

function useJobStatus(jobId: string) {
  const [status, setStatus] = useState<Job | null>(null);
  const [delay, setDelay] = useState<number | null>(2000);

  useInterval(async () => {
    try {
      const job = await getJob(jobId);
      setStatus(job);
      if (job.done) setDelay(null);            // 停止輪詢
      else setDelay(2000);                     // 健康 → 基礎頻率
    } catch {
      setDelay((d) => Math.min((d ?? 2000) * 2, 60_000)); // 退避,封頂 1 分鐘
    }
  }, delay);

  return status;
}

每次 setDelay 都會以新的節奏重啟 interval——而且因為沒開 immediate,一次失敗之後會等滿新的、更長的 delay 再試,這正是退避的意義。那個 hook 裡沒有任何定時器簿記——全是”此刻 delay 應該是多少?“。

後臺分頁(以及離線時)暫停

瀏覽器會節流隱藏分頁裡的定時器——大約降到每秒一次,Chrome 在分頁隱藏五分鐘後更是降到每分鐘一次。在後臺分頁裡輪詢,既浪費配額,會在不可預測的時刻觸發。修法和 null 模式天然可以組合,配上 useDocumentVisibilityuseOnline

const visible = useDocumentVisibility() === "visible";
const online = useOnline();

useInterval(refresh, visible && online ? 10_000 : null, { immediate: true });

使用者回來時,delaynull 翻到 10_000,interval 重啟,immediate 立刻拉一次新資料——正是你原本要用 visibilitychange 監聽器手寫的那套”恢復並追上”行為。

時鐘:排程 tick,別數 tick

setInterval 會漂移。“每 1000ms”跑一分鐘,你可能丟掉一秒甚至更多,被節流的分頁裡尤其如此。所以別在回呼裡累加時間——只拿 interval 觸發重渲染,然後讀真正的時鐘:

function Clock() {
  const [now, setNow] = useState(() => Date.now());
  useInterval(() => setNow(Date.now()), 1000);
  return <time>{new Date(now).toLocaleTimeString()}</time>;
}

interval 允許不準;顯示的值永遠正確,因為它來自 Date.now(),而不是 tick 數 × 1000。已用時長的顯示同理:存一個起始時間戳記,渲染 Date.now() - start

什麼時候不該用 useInterval

  • 你要的是一次延遲呼叫,不是重複呼叫。 那是 useTimeoutFn——const [pending, start, stop] = useTimeoutFn(fn, ms)——或者只需要 N 毫秒後重渲染一次的話用 useTimeout
  • 你在做倒計時顯示。 useCountDown 已經在 useInterval 之上做好了秒 → hh:mm:ss 的換算和完成回呼。
  • 你在做動畫。 任何應該每幀更新的視覺效果都屬於 requestAnimationFrame,也就是 useRafFn 包裝的東西——它與顯示器重新整理率同步,隱藏分頁裡自動暫停。16ms 的 setInterval 不是一回事。
  • 你在給處理器限流,而不是排程它。使用者輸入的尾沿觸發是 useDebounceFn / useThrottleFn 的地盤。
  • 這個”interval”其實是伺服器端推送。 如果伺服器端能告訴你什麼時候變了,走 useEventSource 的 Server-Sent Events 流在延遲和成本上都勝過輪詢。
你想要……
每 N 毫秒跑一次 fn,用 nullpause() 暫停useInterval
N 毫秒後跑一次 fn,帶 start / stopuseTimeoutFn
N 毫秒後重渲染一次useTimeout
從 N 秒開始的 hh:mm:ss 倒計時useCountDown
每個動畫幀跑一次 fnuseRafFn

值得知道的坑

  • 回呼讀到的是最新一次已提交的渲染。 ref 在每次渲染後的 layout effect 裡更新,所以渲染進行中觸發的 tick 看到的是上一次已提交的值——實踐中不是問題,但這就是 hook 無法”比 React 更新”的原因。
  • delay 變化 = 相位重置。 上面講過;如果你需要在丟掉進行中 tick 的前提下改節奏,保持 interval 不變、在回呼裡跳過 tick。
  • immediate 在 effect 裡觸發,掛載時和每次 delay 變化時都會。 React 18+ 開發模式的 StrictMode 下,這意味著掛載時 immediate 呼叫會發生兩次(掛載 → 清理 → 掛載)。像對待任何 effect 一樣,讓它冪等。
  • async 回呼沒問題——但重疊得你自己管。 hook 不會等待回傳的 Promise。如果一次 fetch 可能比 delay 還久,用一個進行中標誌守著,或者在請求掛起期間用 null 暫停。
  • SSR 天然安全。 定時器在 effect 裡建立,伺服器端什麼都不跑,也沒有需要守護的 window 訪問。

要點回顧

  • 卡在 1 的計數器是過期閉包 bug:setInterval 一直拿著第一次渲染的回呼。useInterval 把最新回呼存進 ref,定時器只跑一份、永遠看到當前 state。
  • 只有 delay 會重啟 interval——傳 null 宣告式暫停,或者 controls: truepause() / resume() 做使用者驅動的啟停。
  • immediate: true 現在跑一次、之後每 N 毫秒一次;退避就是 setDelay(...);把 useDocumentVisibility / useOnline 折進 delay 表示式,在隱藏或離線的分頁裡暫停輪詢。
  • 絕不要在 interval 裡累加時間——讀 Date.now()——當任務不是”每 N 毫秒,永遠”時,換 useTimeoutFnuseCountDownuseRafFn

useIntervaluseTimeoutFnuseCountDown 以及另外 110+ 個 SSR 安全、TypeScript 優先的 hook 都在 @reactuses/core 裡——一次安裝,可 tree-shake,零依賴負擔。

npm install @reactuses/core