useRef 與穩定引用——什麼時候它是逃生口,什麼時候它是壞味道

前面幾篇已經把幾件事講清楚了:
useEffect不該管組件內部的純計算。閉包問題本質上是渲染快照和函數引用的問題。
很多所謂「狀態管理」,其實只是把計算錯寫成了 state。
走到這一步,通常會遇到一個新的問題:
有些東西我不想放進 state,因為它不該觸發重渲染; 但我又確實想讓它跨渲染保留下來。 那它到底該放哪?
答案就是:useRef。
但也正因為它「不觸發重渲染」,所以它非常像一把好用的旁門兵器: 用對了,能解決閉包、訂閱、DOM 控制這些很棘手的問題; 用錯了,組件的數據流就開始偷偷分叉。
一、先把 useRef 的本質說清楚
React 官方對 useRef 的定義非常直接:
它能在多次渲染之間保存一個值。
修改它不會觸發組件重新渲染。
也就是說,useRef 更像一個「跨 render 持久存在的可變盒子」:
const ref = useRef(initialValue);
// ref.current 可讀可寫
ref.current = nextValue;它和普通變數的區別在於:
普通變數每次 render 都會重新執行、重新聲明。
ref 會在組件生命週期裡一直保留同一個對象。
它和 state 的區別在於:
state 改了,React 會重新渲染。
ref 改了,React 不會知道,也不會更新 UI。
所以一句話總結:
useRef適合存「需要記住,但不影響當前界面」的值。
這句話非常關鍵,後面所有判斷幾乎都能從這裡推出來。
二、最典型的兩個 useRef 用途
1. 訪問 DOM
這是大家最熟悉的:
function Input() {
const inputRef = useRef<HTMLInputElement | null>(null);
function focusInput() {
inputRef.current?.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={focusInput}>聚焦</button>
</>
);
}這種場景非常標準,因為你需要一個「指向真實 DOM 節點的句柄」,然後做一些命令式操作,比如:
聚焦。
滾動到某個位置。
讀取尺寸。
和第三方 DOM 庫對接。
這裡 ref 是 React 官方明確提供的 escape hatch:
當聲明式 UI 不夠表達某些行為時,你可以短暫地下潛到 DOM 層。
2. 保存不觸發渲染的持久值
這才是實際項目裡更容易被低估的用法。
比如:
上一次的值。
計時器 ID。
當前訂閱實例。
某個只給 effect / 回調用的最新參數。
例子:
const timerRef = useRef<number | null>(null);
useEffect(() => {
timerRef.current = window.setInterval(() => {
console.log('tick');
}, 1000);
return () => {
if (timerRef.current !== null) {
clearInterval(timerRef.current);
}
};
}, []);這裡如果你把 timerId 放到 state 裡,反而是錯的,因為:
它不參與 UI 渲染。
它只是一個內部控制句柄。
改它沒必要觸發界面刷新。
這類「值要保留,但不該驅動 UI」的場景,才是 useRef 真正大規模發揮作用的地方。
三、useRef 為什麼能解決 stale closure 問題?
前一篇講閉包時提過,很多舊值問題的根源是:
計時器 / 監聽器 / 異步回調註冊在舊 render。
它們拿到的也是舊 render 裡的變量。
而 useRef 之所以常被拿來解這個問題,是因為:
effect 或回調可以始終持有同一個 ref 對象;
而這個 ref 的
.current可以在每次 render 後更新成最新值。
比如:
function SearchBox() {
const [query, setQuery] = useState('');
const queryRef = useRef(query);
useEffect(() => {
queryRef.current = query;
}, [query]);
useEffect(() => {
const id = setInterval(() => {
console.log(queryRef.current);
}, 3000);
return () => clearInterval(id);
}, []);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}這裡計時器只註冊一次,但每次讀到的都是最新 query。
原因不是 React 幫你「熱更新了閉包」,而是閉包拿到的是那個一直沒變的 queryRef 對象,而你每次改的是它裡面的 current。
所以 useRef 在這類場景中的角色是:
不去重建外部訂閱,而是讓訂閱去讀一個持續更新的「最新值容器」。
這點很重要,因為它決定了 ref 該在什麼時候出場。
四、什麼時候應該用 ref,而不是 state?
這是整篇最實用的問題,判斷標準可以很簡單。
用 state 的情況
如果這個值會影響渲染結果,就該用 state。
比如:
彈窗是否打開。
當前選中項。
加載中狀態。
表單輸入值。
因為這些一變,界面就該跟著變。
用 ref 的情況
如果這個值只是組件內部記憶,不直接影響界面,就優先考慮 ref。
比如:
上一個 props 值。
計時器 ID。
一個第三方實例。
是否已經提交過。
滾動位置緩存。
某個供異步回調讀取的最新值。
一句非常好記的話: > 想讓 React 重新畫界面,用 state; > 只想讓組件自己記住點東西,用 ref。
五、一個最容易寫對也最容易寫歪的例子:上一個值
很多教程都喜歡用 usePrevious 當 useRef 示例,因為它確實很典型:
function usePrevious<T>(value: T) {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}然後這樣用:
const prevCount = usePrevious(count);這類代碼成立,是因為:
「上一個值」本身不直接參與當前 UI 的更新機制;
它只是一個輔助信息。
如果你為了拿「上一個值」,去再開一個 state:
const [prevCount, setPrevCount] = useState(count);
useEffect(() => {
setPrevCount(count);
}, [count]);那就又繞回了「effect 同步狀態」的老問題。
所以這裡 ref 很合適,它提供的是「記憶能力」,不是「渲染驅動能力」。
六、什麼時候 useRef 是壞味道?
到這裡,容易進入另一個極端: 既然 ref 不觸發重渲染、還能存最新值,那是不是很多 state 都可以換成 ref?
絕對不是。
下面這些情況,通常都說明你已經在濫用 ref。
1. 用 ref 存本該驅動 UI 的數據
比如:
const openRef = useRef(false);
function handleOpen() {
openRef.current = true;
}然後你還想讓界面跟著顯示彈窗。 這就錯了,因為 React 根本不知道 openRef.current 變了,頁面不會更新。
只要某個值改變後,你期待界面立刻重新反映出來,那它就不該是 ref,而應該是 state。
2. 在 render 期間讀寫 ref,偷偷繞開 React 數據流
React 官方專門提醒過:
不要在渲染期間讀寫
ref.current,除非是初始化這種極少數可預測場景。
比如這種就很危險:
function Component() {
const countRef = useRef(0);
countRef.current += 1;
return <div>{countRef.current}</div>;
}這段代碼的問題在於:
render 本該是純的;
你卻在 render 裡偷偷改了外部可變值;
這樣會讓組件行為變得不可預測,尤其在嚴格模式和未來特性下更危險。
所以 ref 可以變,但不要把它當成「render 階段的可變全局變數」。
3. 用 ref 迴避依賴數組,而不是解決問題
這是團隊裡很常見的一種「高級壞味道」。
比如本來 effect 正常應該依賴 userId,結果有人為了避免重跑,硬是這樣寫:
const userIdRef = useRef(userId);
useEffect(() => {
userIdRef.current = userId;
}, [userId]);
useEffect(() => {
fetchUser(userIdRef.current);
}, []);這段代碼也許「能跑」,但往往是在偷偷改變語義:
原本這個 effect 應該隨
userId變化重跑。現在你把它改成了「只執行一次,但讀最新值」。
這兩者不是一回事。
所以一個很重要的原則是:
ref 應該用來表達「我不想重建這個外部訂閱,但我想讓它讀到最新值」; 而不是用來逃避「這個 effect 本來就該跟依賴變化一起更新」的事實。
七、一個完整重構例子:防抖搜索該怎麼寫
這個場景很適合把 useRef、閉包和穩定引用串起來。
先看一個常見壞代碼:
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
useEffect(() => {
const id = setTimeout(() => {
onSearch(query);
}, 500);
return () => clearTimeout(id);
}, [query, onSearch]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}這段其實不算錯。 它表達的是:每次 query 變化,就重新啟動一個 500ms 定時器。 如果你的意圖就是「隨 query 重建防抖計時」,它是合理的。
但如果你想把「防抖函數」抽成一個穩定工具,就很容易寫出這種有 stale closure 的版本:
function useDebouncedCallback(fn: (...args: any[]) => void, delay: number) {
const timerRef = useRef<number | null>(null);
return useCallback((...args: any[]) => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
fn(...args);
}, delay);
}, [delay]);
}這段的坑在於:
callback 被緩存住了;
但裡面閉包的
fn可能已經過期;如果外部傳進來的函數變了,這裡不一定拿的是最新版本。
更穩妥的寫法是把最新 fn 放到 ref 裡:
function useDebouncedCallback<T extends (...args: any[]) => void>(
fn: T,
delay: number
) {
const fnRef = useRef(fn);
const timerRef = useRef<number | null>(null);
useEffect(() => {
fnRef.current = fn;
}, [fn]);
return useCallback((...args: Parameters<T>) => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
fnRef.current(...args);
}, delay);
}, [delay]);
}這裡的結構就很清楚:
timerRef保存計時器句柄。fnRef保存最新回調。返回的函數只在
delay變化時更新。
這就是 useRef 非常典型的一種高階用途:
讓工具函數保持穩定,同時又能拿到最新業務邏輯。
八、useRef 和 useCallback 的關係,到底怎麼配合?
前一篇已經講過,useCallback 解決的是函數引用穩定,不是最新值問題。
而 useRef 解決的是跨 render 持久保存一個可變值。
所以它們經常搭配出現,但分工不同:
useCallback:決定「這個函數對象要不要換」。useRef:決定「函數內部需要讀的那個最新值放哪」。
一個典型模式是:
const latestHandlerRef = useRef(onChange);
useEffect(() => {
latestHandlerRef.current = onChange;
}, [onChange]);
const stableHandler = useCallback((value: string) => {
latestHandlerRef.current(value);
}, []);這裡:
stableHandler引用是穩定的;但它執行的邏輯始終是最新
onChange。
這類模式在封裝 hook、橋接第三方事件系統時特別常見。
不過要強調一句:
如果你只是普通業務組件,先別急著上這種模式。 很多時候老實寫依賴數組,代碼反而更簡單。
ref + callback 組合適合的是「工具化場景」「訂閱場景」「不想重建場景」,而不是所有地方都上。
九、這一篇可以直接帶走的 7 條規則
useRef是一個跨渲染持久存在的可變盒子,改它不會觸發重渲染。它適合保存「不影響 UI、但需要記住」的值,比如 DOM 節點、計時器 ID、第三方實例、最新回調。
如果某個值變化後應該立刻反映到界面上,那它應該是 state,不是 ref。
stale closure 場景下,ref 常用來讓「長壽命回調」讀到最新值,而不必頻繁重建訂閱。
不要在 render 期間隨意讀寫
ref.current,這會破壞 render 的純度。不要用 ref 去逃避本來就應該存在的 effect 依賴。
useRef和useCallback經常搭配,但一個解決「值持久化」,一個解決「函數引用穩定」,別混為一談。