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>;
}
它顯示 0、1……然後永遠停在 1。interval 的回呼是在第一次渲染時建立的,那時 count 是 0,而空依賴陣列意味著它再也看不到後續的渲染。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 對每一個的解法都不同:
- 過期閉包。
setInterval一輩子只持有一個函式引用。這個函式閉包住了某一次渲染的 props 和 state。之後每次渲染都會建立新的閉包——而正在跑的 interval 永遠看不到。 - 一渲染就重啟。 顯而易見的修法是讓 effect 依賴回呼讀到的所有東西:
useEffect(..., [count])。現在 interval 正確了,但每次變化都被清掉重建——計時每次歸零,依賴變得快的話,tick 可能一次都觸發不了。 - 生命週期。 你得在卸載時清除 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/core 裡 useInterval 的核心——它用 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>
</>
);
}
pause 和 resume 的引用是穩定的(放進依賴陣列和事件處理器都安全),而且即使在 controls 模式下,interval 依然會在卸載時清除——你不可能因為忘了而漏掉一個定時器。一句實話:回傳值裡的 isActive 是一個 ref(isActive.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 模式天然可以組合,配上 useDocumentVisibility 和 useOnline:
const visible = useDocumentVisibility() === "visible";
const online = useOnline();
useInterval(refresh, visible && online ? 10_000 : null, { immediate: true });
使用者回來時,delay 從 null 翻到 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,用 null 或 pause() 暫停 | useInterval |
N 毫秒後跑一次 fn,帶 start / stop | useTimeoutFn |
| N 毫秒後重渲染一次 | useTimeout |
從 N 秒開始的 hh:mm:ss 倒計時 | useCountDown |
每個動畫幀跑一次 fn | useRafFn |
值得知道的坑
- 回呼讀到的是最新一次已提交的渲染。 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: true配pause()/resume()做使用者驅動的啟停。 immediate: true現在跑一次、之後每 N 毫秒一次;退避就是setDelay(...);把useDocumentVisibility/useOnline折進 delay 表示式,在隱藏或離線的分頁裡暫停輪詢。- 絕不要在 interval 裡累加時間——讀
Date.now()——當任務不是”每 N 毫秒,永遠”時,換useTimeoutFn、useCountDown或useRafFn。
useInterval、useTimeoutFn、useCountDown 以及另外 110+ 個 SSR 安全、TypeScript 優先的 hook 都在 @reactuses/core 裡——一次安裝,可 tree-shake,零依賴負擔。
npm install @reactuses/core