2026年5月20日
Ref 逃生艙 React Hook 解決閉包陳舊、回呼身分不穩和強制更新
每個函式元件在每次渲染時都會從頭跑一遍,渲染期間建立的每個閉包,捕獲的都是那一刻的 props 和 state。這句話就是 React 模型的全部,同時也是一整族 bug 的源頭
count 的事件處理函式、因為回呼身分每次都變而每次渲染都重新訂閱的useEffect、在元件已經卸載之後才觸發的 setState。它們看起來是不同的問題,其實是同一個問題——一個閉包死死攥著一張早已過期的快照。
對於「我需要一個跨渲染存活、又不被閉包捕獲的值」,React 官方的答案是 useRef。ref 是一個身分永不改變的可變盒子;讀 ref.current 拿到的永遠是當前值,而不是閉包建立時那個。這就是逃生艙。麻煩在於,把 ref 接對——保持同步、在正確的時機讀取、不破壞 SSR——足夠瑣碎,以至於每個人都寫出一個略有差異的版本,而其中有些版本會產生競態。
ReactUse 把這些都做成了產品級實作。本文走一遍其中七個,每個的原始碼都不超過十幾行;它的價值在於,這是正確的那十幾行,在每個專案裡都一樣。如果你讀過上週那篇專門 effect hook 的文章,這篇是它的姊妹篇
hook 修的是useEffect,這些修的是流經它的閉包。
把 bug 說具體
下面這個聊天元件輪詢未讀訊息並顯示數量。它錯得很隱蔽,能輕鬆通過 code review:
function Inbox({ userId }: { userId: string }) {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// BUG:這裡的 `count` 永遠是 0——它是 effect 首次執行時捕獲的值。
// 這個計時器永遠看不到更新後的 count。
console.log(`Polling, current count is ${count}`);
fetchUnread(userId).then((n) => setCount(count + n));
}, 5000);
return () => clearInterval(id);
}, [userId]); // 故意不放 count,否則每次變化計時器都會重建
return <Badge>{count}</Badge>;
}
計時器回呼閉包捕獲的是 effect 執行那次渲染裡的 count。那時 count 是 0,於是它在那個閉包裡永遠是 0——setCount(count + n) 實際上是 setCount(0 + n)。常見的「修復」各自又換來一個新 bug
count 加進依賴陣列,計時器就每五秒銷毀重建一次;改用 setCount((c) => c + n) 更新函式,寫是修好了,但 console.log 仍在撒謊,任何需要在 setter 之外讀取最新 count 的邏輯依舊卡住。
你真正想要的是
,觸發時仍能讀到最新的count。這就是 ref。下面這些 hook 就是補齊了人體工學的 ref。
1. useLatest——永遠讀到當前值
useLatest 接收一個值,回傳一個永遠持有它最新版本的 ref。這個 ref 的身分永不改變,所以任何閉包捕獲它——計時器、事件監聽器、長期存活的回呼——都會透過它讀到今天的值,而不是訂閱那一刻凍結的值。
import { useLatest } from "@reactuses/core";
function Inbox({ userId }: { userId: string }) {
const [count, setCount] = useState(0);
const countRef = useLatest(count);
useEffect(() => {
const id = setInterval(() => {
// 即便 effect 只執行了一次,countRef.current 永遠是最新的 count。
console.log(`Polling, current count is ${countRef.current}`);
fetchUnread(userId).then((n) => setCount(countRef.current + n));
}, 5000);
return () => clearInterval(id);
}, [userId]); // 依賴裡沒有 count——計時器是穩定的
return <Badge>{count}</Badge>;
}
effect 只依賴 userId,於是計時器只建立一次,熬過每一次 count 變化。讀取走 countRef.current,而 useLatest 透過在每次渲染的 layout effect 裡寫入它來保持其最新。這是這一族裡最有用的成員
useLatest 就是答案。
2. useEvent——身分穩定、又總能看到最新 state 的回呼
useLatest 解決的是透過穩定參考讀取一個值。useEvent 為一個函式解決同樣的問題
正是這個 hook,讓你能把一個處理函式傳給被 memo 的子元件而不破壞它的 memo:
import { useEvent } from "@reactuses/core";
function SearchBox({ onResults }: { onResults: (r: Result[]) => void }) {
const [query, setQuery] = useState("");
const [filters, setFilters] = useState<Filters>(defaultFilters);
// 身分穩定,但每次呼叫都讀取最新的 query 和 filters。
const search = useEvent(() => {
runSearch(query, filters).then(onResults);
});
// <ExpensiveButton> 被 React.memo 了。因為 search 身分永不變,
// 按鈕在 query/filter 的每次按鍵時都不會重渲染。
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ExpensiveButton onClick={search}>Search</ExpensiveButton>
</>
);
}
沒有 useEvent,你會去用 useCallback(() => runSearch(query, filters), [query, filters])——它在每次按鍵時產出一個新的 search,讓按鈕上的 React.memo 失效。把依賴砍成 [],閉包又陳舊了,永遠在搜尋那個空的初始 query。useEvent 兩者兼得
useEffectEvent / 舊的 useEvent RFC 是同一個想法——今天就能用,不需要 canary 建置。把它用在你向下傳遞的事件處理函式和回呼上;在你確實想要重跑的依賴陣列裡別用它。
3. useMountedState——別在卸載後 setState
「Can’t perform a React state update on an unmounted component」這個警告來自一個非同步操作在元件已經消失之後才完成。修法是一個在卸載時翻轉的旗標,在每次遲到的 setState 前檢查它。useMountedState 就是這個旗標,以 getter 背後的 ref 形式存在:
import { useMountedState } from "@reactuses/core";
function UserCard({ id }: { id: string }) {
const [user, setUser] = useState<User | null>(null);
const isMounted = useMountedState();
useEffect(() => {
fetchUser(id).then((u) => {
// 這個 fetch 可能在使用者已經離開之後才完成。
if (isMounted()) setUser(u);
});
}, [id]);
return user ? <Card user={user} /> : <Spinner />;
}
isMounted 是一個穩定的 getter——呼叫它會從 ref 裡回傳當前掛載狀態,所以你可以在任何非同步回呼裡呼叫它而不必把它加進依賴陣列。它故意是函式而不是布林值
AbortController,但 useMountedState 覆蓋了 abort 訊號搆不著的場景——計時器、第三方 promise、訂閱回呼。
4. usePrevious——和上一次渲染對比
有時你需要上一次渲染的值來決定這一次怎麼做
、僅當某個值真的從某個舊值變化時才觸發 effect、記錄狀態轉移。usePrevious 正好把它遞給你:
import { usePrevious } from "@reactuses/core";
function Price({ value }: { value: number }) {
const previous = usePrevious(value);
const direction =
previous === undefined ? "flat" : value > previous ? "up" : value < previous ? "down" : "flat";
return <span className={`price price--${direction}`}>${value.toFixed(2)}</span>;
}
首次渲染時 previous 是 undefined(此前沒有渲染過),之後每次渲染它都持有上一次渲染的值。ReactUse 的實作用渲染期間的 state 更新來追蹤它,而不是樸素的「在 effect 裡寫 ref」做法——這很重要,因為基於 effect 的版本在渲染過程中本身會報錯誤的值。了解一下這個 hook 內部怎麼做的有好處,但重點是你不用再重複實作它了。
5. useFirstMountState——判斷是不是第一次渲染
一個近親
值,而僅僅是知道這是不是第一次渲染。useFirstMountState 在首次渲染回傳 true,之後每次回傳 false——同步地,在渲染期間,早於任何 effect 執行之前。
import { useFirstMountState } from "@reactuses/core";
function Analytics({ route }: { route: string }) {
const isFirstMount = useFirstMountState();
useEffect(() => {
// 區分初始頁面載入和之後的客戶端導航。
track(isFirstMount ? "page_view_initial" : "page_view_spa", { route });
}, [route]);
return null;
}
它是 useUpdateEffect 這類「跳過 mount」effect hook 背後的積木——但直接暴露出來,供你在渲染邏輯裡(而不僅是在 effect 裡)拿到這個布林值。因為它在渲染期間讀取(不等 effect),你可以用它來選擇初始樣式、決定是否動畫、或分支 JSX,這些都是基於 effect 的「已掛載」旗標來不及做到的。
6. useUpdate——按需強制重渲染
ref 對 React 的渲染週期是隱形的
ref.current 不會排程渲染。通常這正是它的意義所在。偶爾你有真正活在 React 之外的狀態——一個 ref 上的值、一個外部 store、一個可變實例——你需要告訴 React「有東西變了,重畫一次」。useUpdate 回傳一個只做一件事的函式。
import { useUpdate, useLatest } from "@reactuses/core";
function StopwatchDisplay({ stopwatch }: { stopwatch: ExternalStopwatch }) {
const update = useUpdate();
useEffect(() => {
// 這個碼錶自己改自己的 elapsed 時間;它不活在 React state 裡。
// 訂閱它,每個 tick 強制渲染一次,讓顯示跟上。
return stopwatch.onTick(() => update());
}, [stopwatch, update]);
return <time>{stopwatch.elapsed}ms</time>;
}
update 身分穩定,所以放在依賴陣列和 effect 體裡都安全。要節制使用——大多數「我需要強制渲染」的直覺,用真正的 state 來滿足更好——但對於把一個外部可變源接進 React 渲染週期,它是精準的工具,而且比人們到處抄的 useReducer((x) => x + 1, 0) 咒語清晰得多。
7. useMergedRefs——讓多個 ref 指向同一個節點
最後一個是另一種風味的 ref 問題
,而是組合。一個 DOM 節點只能交給一個ref prop,但你經常有好幾個消費者各自都需要它——你自己的測量 ref、來自父元件的轉發 ref、還有某個函式庫的 ref(拖曳把手、焦點陷阱、交叉觀察器)。useMergedRefs 把它們合併成一個 ref 回呼,把節點分發給所有人:
import { forwardRef, useRef } from "react";
import { useMergedRefs } from "@reactuses/core";
const Field = forwardRef<HTMLInputElement, FieldProps>(function Field(props, forwardedRef) {
const localRef = useRef<HTMLInputElement>(null); // 我們想自己測量/聚焦它
const mergedRef = useMergedRefs(localRef, forwardedRef);
// localRef.current 和父元件的 ref 都指向同一個 input。
return <input ref={mergedRef} {...props} />;
});
它同時處理兩種形態的 ref——物件 ref({ current })和回呼 ref((node) => …)——並把節點賦給每一個。這消除了 React 元件庫作者生活裡最繁瑣的樣板
setRef 輔助函式。
拼到一起
開頭那個 Inbox bug,用工具箱而不是繞著它寫:
import { useLatest, useMountedState, useEvent } from "@reactuses/core";
function Inbox({ userId, onOpen }: { userId: string; onOpen: (id: string) => void }) {
const [count, setCount] = useState(0);
const countRef = useLatest(count);
const isMounted = useMountedState();
useEffect(() => {
const id = setInterval(() => {
fetchUnread(userId).then((n) => {
if (isMounted()) setCount(countRef.current + n); // 新鮮的 count,沒有遲到更新
});
}, 5000);
return () => clearInterval(id);
}, [userId]); // 穩定計時器——count 變化時不重建
// 給被 memo 的列用的穩定處理函式,總是讀到最新的 count。
const handleOpen = useEvent(() => {
track("inbox_open", { unread: countRef.current });
onOpen(userId);
});
return <InboxButton onClick={handleOpen} badge={count} />;
}
三個 hook,關掉三類閉包 bug
state 的穩定計時器(useLatest)、沒有卸載後 setState(useMountedState)、以及一個不破壞被 memo 子元件的穩定處理函式(useEvent)。沒有依賴陣列體操,沒有 setRef 輔助函式,沒有 useReducer 強制更新的小把戲。
上手試試
每個 hook 在它的文件頁都有可執行的 demo——打開一個,改改輸入,看看什麼保持穩定、什麼保持新鮮:
用 npm install @reactuses/core(或 pnpm add @reactuses/core)安裝後直接 import。沒有 provider,除了 React 16.8+ 之外沒有 peer 依賴。完整 hook 列表和上面所有內容的原始碼都在 reactuse.com。
心智模型就是全部
,閉包捕獲這張快照,而useRef 是出去的那扇門。這七個 hook 就是這扇門,而且鉸鏈已經上好了油。