2026年8月16日
React useLatest Hook:在非同步回呼裡讀到最新狀態 (2026)
這是一個會對使用者撒謊的自動儲存按鈕:
function Editor({ docId }: { docId: string }) {
const [text, setText] = useState("");
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "dirty">("idle");
async function save() {
setStatus("saving");
await api.save(docId, text);
setStatus("saved"); // ⚠️ 但使用者在 await 期間還在打字……
}
return (
<>
<textarea value={text} onChange={e => setText(e.target.value)} />
<button onClick={save}>Save</button> <em>{status}</em>
</>
);
}
請求耗時 800 毫秒。請求在飛的時候使用者又敲了三個詞。Promise 兌現,status 翻成 "saved",而那三個新詞根本沒被儲存。在 save 裡面,text 是按鈕被點擊那一刻的值——JavaScript 閉包捕獲了那次渲染的值,之後再多的重新渲染也改不了它。要在 await 之後判斷到底是 "saved" 還是 "dirty",你需要知道 text 現在是什麼,而閉包告訴不了你。
這就是過期閉包(stale closure),任何一個活得比建立它的那次渲染更久的回呼都會遇到:setTimeout、setInterval、await 之後的程式碼、只註冊一次的事件監聽器、IntersectionObserver 和 ResizeObserver 的回呼、WebSocket 的 onmessage,以及每一個在構造時接收回呼的第三方 SDK。React 官方 FAQ 對*“為什麼我在函式里看到的是過期的 props 或 state?“*給出的答案,就是一個永遠持有最新值的 ref。@reactuses/core 裡的 useLatest 就是把這個 ref 打包好:五行程式碼,不觸發重新渲染,沒有依賴陣列。本文會講清楚它是什麼、為什麼實現裡是在 layout effect 而不是渲染期間寫 ref、它跟 useRef、useEvent 和 React 的 useEffectEvent 是什麼關係、它為哪些模式而生,以及你必須遵守的唯一一條規矩。
快速上手
npm install @reactuses/core
import { useLatest } from "@reactuses/core";
import { useState } from "react";
function Editor({ docId }: { docId: string }) {
const [text, setText] = useState("");
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "dirty">("idle");
const latestText = useLatest(text);
async function save() {
const snapshot = text; // 閉包:我們要發出去的是什麼
setStatus("saving");
await api.save(docId, snapshot);
// ref:使用者現在手上的是什麼
setStatus(latestText.current === snapshot ? "saved" : "dirty");
}
// …
}
useLatest(value) 返回一個 MutableRefObject<T>,它的 .current 永遠是最近一次渲染的 value。ref 物件本身的身份從不改變,所以在任何地方閉包捕獲它都是安全的——定時器、Promise、訂閱——等回呼最終執行時再讀就行。注意修好的版本同時用了閉包和 ref:閉包是點擊那一刻的值(回答”我們發了什麼?“是對的),ref 是 Promise 兌現那一刻的值(回答”它還是最新的嗎?“是對的)。過期閉包不是 JavaScript 的 bug;只有當你想要的是現在、拿到的卻是那時,它才是 bug。
useLatest 到底是什麼
@reactuses/core 裡的完整實現:
import { useRef } from "react";
import { useIsomorphicLayoutEffect } from "@reactuses/core";
function useLatest<T>(value: T) {
const ref = useRef(value);
useIsomorphicLayoutEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
就這些。一個只建立一次的 ref,每當 value 變化就覆寫它的 .current。有兩個細節值得弄明白,因為手寫版本的差異正在這裡。
為什麼用 layout effect,而不是在渲染函式體裡直接 ref.current = value? 幾個流行實現(react-use、ahooks)就是在渲染期間賦值的,絕大多數時候也能用。但 React 的規則要求渲染必須是純的——渲染期間不讀也不寫 ref.current——因為在併發渲染下,一次渲染可以被開始、暫停,然後在從未提交的情況下直接丟棄。在一次被丟棄的渲染裡寫下的 ref,現在持有的是一個沒有任何已提交 UI 顯示過的值。在 useLayoutEffect 裡寫,意味著 ref 每次已提交的渲染恰好更新一次,在 DOM 更新之後、瀏覽器繪製之前同步完成。這跟 React useEvent RFC 用的是同一個技巧,也是為什麼 @reactuses/core 的 useEvent、useInterval、useTimeoutFn 和另外十幾個 hook 都建在 useLatest 之上,而不是裸的渲染期賦值。
為什麼不用普通的 useEffect? 時序。passive effect 在繪製之後執行,而同一次提交裡 React 先跑子元件的 effect 再跑父元件的,先跑靠前的 hook 再跑靠後的。如果同一次提交裡有另一個 effect 在更新用的 effect 之前讀了這個 ref,它看到的就是上一次渲染的值。layout effect 跑在所有 passive effect 之前,所以等到任何 useEffect、事件處理器、定時器或 Promise 回呼觸發時,ref.current 已經是最新的了。(useIsomorphicLayoutEffect 在瀏覽器裡就是 useLayoutEffect,在伺服器端就是 useEffect,所以沒有 SSR 警告。)
你需要內化的結論是:.current 反映的是最近一次已提交的渲染,它是給回呼讀的,不是給渲染讀的。 在第 N+1 次更新的渲染期間,ref.current 還持有第 N 次的值——這沒問題,因為在渲染裡你本來就該直接讀 value。如果你發現自己在 JSX 裡寫 {latest.current},你要的其實是普通的 state。
useLatest vs useRef vs useState
這三個經常被搞混,因為它們都”存一個值”。關鍵問題是誰需要這個值、什麼時候需要。
| 你需要…… | 用 |
|---|---|
| 渲染這個值,並在它變化時重新渲染 | useState |
| 跨渲染儲存一個不由 prop/state 派生的可變值(定時器 id、DOM 節點、計數器) | useRef |
| 在一個活得比渲染更久的回呼裡讀到最新的 prop 或 state | useLatest |
useLatest 就是 useRef 加上一個”幫我保持同步”的 effect。如果你寫過這個:
const textRef = useRef(text);
useEffect(() => { textRef.current = text; }, [text]);
……那就是 useLatest(text),只差上面說的 layout effect 時序細節。它也比另一種常見的變通寫法——在 setter 旁邊把 state 複製進 ref(setText(v); textRef.current = v;)——誠實得多,後者一旦有別的東西更新了 text(一個重置按鈕、一個 prop、一個表單庫),就會悄無聲息地壞掉。
useLatest vs useEvent vs useEffectEvent
再看幾個近鄰。三者都是為了對付過期閉包而存在的;區別在於它們包的是什麼。
useLatest(value)包一個值,給你一個 ref。你在已有的任何回呼裡讀.current。useEvent(fn)包一個函式,給你一個穩定的函式,它總是呼叫最新的fn。內部就是useLatest(fn)加useCallback(() => ref.current(...args), [])。當回呼本身就是你要交給子元件、effect 或訂閱的東西,並且你希望它的身份永不改變時用它。useEffectEvent(React 19.2+)是useEvent的內建版本,但限制只能從 effect 裡呼叫——返回的函式不穩定,不能作為 prop 傳遞,也不能加進依賴陣列。
經驗法則:包函式用 useEvent;包值用 useLatest。 兩者可以組合——經典的”只訂閱一次、對最新狀態做出反應”,通常要麼給處理器套一個 useEvent,要麼給它讀的每個值各套一個 useLatest,兩種都行。useLatest 完勝的場景是回呼根本不歸你包:SDK 的 onChange、一個 Promise 的後續、一個你只構造一次的 Observer。
模式
在 await 之後
開頭的自動儲存就是這個形狀:任何先 await、然後需要知道世界有沒有變的處理器。第二個常見變體是發生在處理器(而不是 effect)裡的請求競態:
function Search() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Item[]>([]);
const latestQuery = useLatest(query);
async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const q = e.target.value;
setQuery(q);
const items = await api.search(q);
if (latestQuery.current !== q) return; // 更新的一次按鍵贏了——丟掉這個響應
setResults(items);
}
return <input value={query} onChange={onChange} />;
}
在 useEffect 裡,你會用 React 文件裡的 let ignore = false 清理標誌來做這件事。事件處理器沒有清理的位置,所以由 ref 來承擔”我還相關嗎?“這個檢查。(要對呼叫本身做防抖,看 useDebounceFn——那是另一個問題。)
只建立一次的訂閱
任何建立起來昂貴或有狀態的東西——地圖、圖表、WebSocket、ResizeObserver——都應該只建立一次然後讀取最新狀態,而不是每敲一個鍵就被拆掉重建:
function PinMap({ filters }: { filters: Filters }) {
const container = useRef<HTMLDivElement>(null);
const latestFilters = useLatest(filters);
useEffect(() => {
const map = new mapboxgl.Map({ container: container.current!, style: STYLE });
map.on("moveend", () => {
loadPins(map.getBounds(), latestFilters.current); // 最新的 filters,地圖只建一次
});
return () => map.remove();
}, []); // ✅ 這裡的空依賴是誠實的——裡面沒有任何東西會過期
return <div ref={container} />;
}
沒有這個 ref,你的選擇是把 filters 放進依賴(每次篩選變化地圖就銷燬重建——閃爍、丟失視口、重新下載圖磚),或者空依賴陣列外加一條 lint 警告和一個 bug。useLatest 給了你第三個選項:這個 effect 真的什麼都不依賴,因為它是透過一個永遠最新的 ref 去讀的。
定時器
setTimeout 和 setInterval 是教科書級的過期閉包製造機:
function Toast({ message, onDismiss }: { message: string; onDismiss: () => void }) {
const [paused, setPaused] = useState(false);
const latestPaused = useLatest(paused);
useEffect(() => {
const id = setTimeout(() => {
if (!latestPaused.current) onDismiss(); // 第 5 秒正好停留著?那就保持開啟
}, 5000);
return () => clearTimeout(id);
}, []);
return <div onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>{message}</div>;
}
一次性的以外都別手寫:useTimeoutFn 和 useInterval 已經通過 useLatest 保持回呼最新,並在其上加了 pause/resume/immediate——本系列的上一篇 React useInterval Hook 就是逐行講這個的。
@reactuses/core 內部用在哪裡
如果你想在生產程式碼裡看這個模式,useLatest 是這個庫很大一塊功能背後默默幹活的主力。useEventListener 用它包住你的處理器,這樣 addEventListener 每個元素只跑一次,而不是每次渲染跑一次。useClickOutside、useIntersectionObserver、useResizeObserver 和 useMutationObserver 都只構造一次 observer,然後在裡面呼叫 savedCallback.current。useRafFn 在不取消動畫迴圈的前提下讀最新的幀回呼。useUnmount 用它保證你在第一次渲染時傳入的清理函式,不會在解除安裝時帶著第一次渲染的值執行。同樣的五行,每一次。
值得知道的坑
- 它不是響應式的。 寫或讀
.current從不觸發重新渲染。如果一個變化應該出現在螢幕上,它屬於 state——useLatest是給讀的回呼用的,不是給顯示的值用的。 - 不要在渲染期間讀它。 因為 ref 是在 layout effect 裡更新的,在渲染期間它落後一次提交。這是設計使然,從回呼裡讀時永遠不會有問題;但如果你把
latest.current放進 JSX 或useMemo,問題立刻出現。那些地方直接讀值本身。 - 不要把它放進依賴陣列指望它觸發什麼。 ref 的身份在元件整個生命週期裡都是穩定的,所以
[latestFoo]等價於[]。這是特性——意味著讀它的 effect 永遠不會因為它而重跑——但也意味著你不能用它來響應變化。 - 滯後視窗是真實存在的,也是極小的。 在渲染和 layout effect 提交之間,
.current是上一次渲染的值。這個窗口裡不會有任何使用者可見的東西執行(沒有事件、沒有定時器、沒有 passive effect),所以實踐中不是問題,而這正是永遠不把被丟棄的渲染洩漏進 ref 所付出的代價。 - 有時候重啟正是你要的。 如果你的 effect 應該在某個值變化時重新執行——
roomId變了就重連 socket——那就照常把roomId放進依賴。useLatest只用於那些回呼應該不引起重啟地讀取的值。在同一個 effect 裡兩者混用(依賴裡放[roomId],裡面讀latestFilters.current)完全正常。 - SSR 安全。 它就是一個 ref 加一個同構 layout effect;不碰
window,也不會有 hydration 不匹配,因為它從不渲染任何東西。
什麼時候不該用 useLatest
- 這個值要顯示出來 → 永遠是
useState。 - 你在包一個要交給子元件或 effect 的函式 →
useEvent(或者 React 19.2+ 上、只在 effect 內使用的useEffectEvent)。 - 你想要上一次渲染的值 →
usePrevious——useLatest的映象。 - 你想在
await之後 set state 之前知道元件是否還掛載著 →useMountedState就是那個布林值。 - “過期”的是定時器或 DOM 回呼 → 你多半想要的是
useInterval、useTimeoutFn或useEventListener,它們已經替你把useLatest這套走完了。
要點
- 一個活得比自己那次渲染更久的回呼——定時器、
await、訂閱、SDK 鉤子——看到的是建立它那次渲染的 props 和 state。這是閉包在盡本分;只有當你需要現在卻拿到那時,它才是 bug。 useLatest是一個在 layout effect 裡與某個值保持同步的 ref:從任何回呼讀都是最新的,從不引起重新渲染,從不改變身份,從不洩漏被丟棄的渲染。- 值 →
useLatest。函式 →useEvent。要顯示 →useState。上一次渲染 →usePrevious。 - 從回呼裡讀
.current,永遠不要從渲染裡讀;把真正的重啟觸發條件(roomId、url)留在依賴陣列裡——useLatest是給回呼透過它去讀的東西用的,不是給重啟它的東西用的。
useLatest、useEvent、usePrevious 以及另外 110+ 個 SSR 安全、TypeScript 優先的 hooks 都在 @reactuses/core 裡——一次安裝,可 tree-shake,沒有需要你照看的依賴。
npm install @reactuses/core