React usePrevious Hook:追蹤先前的 State 與 Props (2026)
React 讓你在每次渲染時都能取得 state 與 props 的目前值,卻沒有內建的方式可以查詢「之前」的值。因此大家都會複製同一段十行左右的寫法:把值存到 ref 裡面,放在 useEffect 中更新,最後回傳 ref.current。這個方法在示範時沒問題,也能部署到正式環境,但有一天「count 上升 ↑」的指示器卻開始宣稱值沒有改變——那是因為元件因為其他完全無關的原因重新渲染,而 ref 悄悄地覆寫了自己的歷史紀錄。
來自 @reactuses/core 的 usePrevious 追蹤的是先前的「不同值」,而非前一次「渲染」的值。它採用 React 官方文件推薦的模式——不使用 ref、不使用 effect、不會產生漂移。整個實作只有十二行,這篇文章會帶你了解經典寫法的真正 bug、為什麼修正看起來違法卻是合法的,以及一個可能導致無限迴圈的陷阱。全面支援 TypeScript。
經典寫法,以及它失效的地方
這段程式碼出現在成千上萬的部落格文章中,可能也出現在你的程式碼庫裡:
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
Enter fullscreen mode Exit fullscreen mode
渲染之後,effect 才會把目前的值複製到 ref。下一次渲染時讀取 ref,就能取得上一次渲染的值。微妙之處就在最後這句話:這個 hook 回傳的是前一次「渲染」的值,而非前一個「值」。只有在元件重新渲染的唯一原因就是該值改變時,這兩者才會相同。但現實中很少能維持這種狀態。
來看看它如何壞掉。以下是一個計數器,附帶方向指示器,以及一個無關的 state:
function Counter() {
const [count, setCount] = useState(0);
const [dark, setDark] = useState(false);
const prevCount = usePrevious(count); // ref 版本
return (
<div>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setDark(!dark)}>toggle theme</button>
{prevCount !== undefined && prevCount !== count && (
<span>{count > prevCount ? "↑ went up" : "↓ went down"}</span>
)}
</div>
);
}
Enter fullscreen mode Exit fullscreen mode
點擊 +1: count 變成 5,prevCount 是 4,指示器顯示「↑ went up」。正確。接著點擊 toggle theme: count 沒有改變,但元件重新渲染,effect 再次執行,ref 變成 5。下一次渲染時 prevCount === count,指示器消失——元件現在認為 count 從未改變。任何來自父層的重新渲染、context 更新,或是兄弟元件的 state 改變,都會造成同樣的結果。你建立這個 hook 所要做的比較,恰恰就是 hook 本身破壞的。
這不是假設情境:同樣的問題也出現在 react-use、ahooks 以及 reactuse 本身 在更換實作之前所收到的回報。
usePrevious — 先前的「值」,而非先前的「渲染」
import { useState } from 'react';
import { usePrevious } from '@reactuses/core';
function Counter() {
const [count, setCount] = useState(0);
const [dark, setDark] = useState(false);
const prevCount = usePrevious(count);
return (
<div>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setDark(!dark)}>toggle theme</button>
<p>Now: {count}, before: {prevCount ?? "—"}</p>
</div>
);
}
Enter fullscreen mode Exit fullscreen mode
簽章:
function usePrevious<T>(value: T): T | undefined;
Enter fullscreen mode Exit fullscreen mode
隨意切換主題——prevCount 仍然是 4,因為 count 實際上沒有改變。第一次渲染時會回傳 undefined,因為還沒有先前的值;請將你的比較型別宣告為 T | undefined。
實作 短到可以完整引用,而且不使用 ref 也不使用 effect:
export function usePrevious<T>(value: T): T | undefined {
const [current, setCurrent] = useState<T>(value);
const [previous, setPrevious] = useState<T>();
if (value !== current) {
setPrevious(current);
setCurrent(value);
}
return previous;
}
Enter fullscreen mode Exit fullscreen mode
等等——在渲染期間呼叫 setState?
沒錯,這不是 hack:這是 React 官方文件在 storing information from previous renders 所提到的模式。在渲染時呼叫 setter 在以下兩個條件下是合法的,而這段程式碼滿足了這兩個條件:
- 這是元件自己的 state。 React 會透過丟棄目前渲染的輸出,並立即以新 state 重新執行元件來處理渲染階段的更新——在碰觸 DOM、繪製畫面,或執行任何 effect 之前。不會有中間畫面被看到。
-
它位於一個最終會停止的條件之後。
value !== current只會在值改變後的那一次渲染為 true;重新執行後會看到value === current並直接跳過。不會產生迴圈。
比較兩種版本各自錨定的歷史紀錄。ref 的寫法記錄的是「上一次渲染時的值」——因此每次渲染都會重寫歷史,不管有沒有相關。state 的版本記錄的是「上一次改變前的值」——無關的重新渲染會遇到 value === current 而不做任何事。這個單一的守衛就是整個 bug 的修正。
它也能在 React 18 更嚴格的執行模型下正常運作,而 effect + ref 的版本則變得不穩定。StrictMode 會在開發模式下讓 render 函式執行兩次:在這裡,第二次呼叫會用相同的 state 進行相同的比較,並得到相同的結果——具有冪等性。並行功能可能在提交前丟棄渲染:被丟棄渲染的 state 更新也會一起被丟棄,而在渲染中進行的 ref 變更(另一種常見的「修正」)則會逃出渲染,洩漏到官方上不存在的時間軸中。在渲染期間更新 state 是唯一在所有情境下都正確的變體。
陷阱:不穩定的物件
比較使用 !==——嚴格的參考相等性。如果每次渲染都傳入一個新的物件字面值,value !== current 就會「永遠」為 true:
// 💥 Too many re-renders
const prev = usePrevious({ x: position.x, y: position.y });
Enter fullscreen mode Exit fullscreen mode
每次渲染都建立新物件,守衛被觸發,渲染階段的 setState 引發重新執行,而這又建立「另一個」新物件,最後 React 會以「Too many re-renders」停止整個過程。修正方式是常見的參考穩定性原則:傳入基本型別,或是將物件 memoize,讓它的身份只有在內容改變時才改變:
const point = useMemo(() => ({ x, y }), [x, y]);
const prevPoint = usePrevious(point); // ✅
Enter fullscreen mode Exit fullscreen mode
基本型別——數字、字串、布林值——永遠是安全的,而且這也是你九成情況下會追蹤的東西。(如果你真正的問題是「當深層巢狀物件『實際上』改變時執行 effect」,那是 useDeepCompareEffect 的工作,而不是這個 hook。)
usePrevious 與 useLatest 的比較
這兩個容易被搞混,因為它們都是「跨越時間保存值的 hook」,但它們回答的是相反的問題:
-
usePrevious回答的是 「這個值在改變之前是什麼?」 —— 用於渲染期間的比較:改變方向、from/to 標籤、轉換偵測。 -
useLatest回答的是 「這個值現在是什麼?」 —— 用於過時閉包內部,例如setInterval回呼、debounced 處理函式、或在 mount 時註冊一次的事件監聽器。
如果你正在渲染差異,請使用 usePrevious。如果回呼一直看到舊值,請使用 useLatest。需要其中一個,絕不代表你也需要另一個。
實際使用情境
-
改變方向。 排序箭頭、價格走勢、捲動方向、「↑ 比昨天多 3」——任何從
value > prev渲染出來的東西。這正是 ref 寫法明顯失效的情境,因為一次無關的重新渲染就會清除方向。 -
轉換偵測。 當 prop 跨越邊界時觸發邏輯,而不僅僅是當它處於某種狀態時:
prevStatus === "loading" && status === "success"只會在請求完成時顯示 toast 一次。搭配useUpdateEffect也可以跳過 mount 時的渲染。 -
From → to 動畫。 數字計數器和圖表轉場需要兩個端點;
usePrevious讓你取得 tween 的起始值,而不需要額外的 state。 -
「從 X 變成 Y」的 UI。 審計樣式的表單和設定面板,顯示哪些編輯正在等待中——並排渲染
prev和value;在第一次渲染時prev是undefined,因此不顯示任何內容。
SSR 安全性
usePrevious 是兩個 useState 呼叫加上一次比較——沒有 window、沒有 document、沒有 effect,沒有需要防護的東西。在伺服器上它渲染一次並回傳 undefined,客戶端第一次渲染也回傳相同的 undefined,hydration 因此自然匹配。不同於那些讀取瀏覽器狀態的 hook(cookie、localStorage、媒體查詢),這裡沒有伺服器/客戶端差異需要特別設計。它以最無聊的方式達成 SSR 安全:什麼都不做。
重點整理
-
經典的
useRef+useEffect寫法追蹤的是前一次「渲染」,而非前一個「值」——任何無關的重新渲染都會悄悄重寫它,這正是每個使用這個寫法的主要 hooks 函式庫所收到的 bug 回報。 -
usePrevious使用渲染期間的 setState——這是 React 文件認可的模式:有條件、自我終止、對使用者不可見,且在 StrictMode 與並行渲染下都正確。 -
第一次渲染回傳
undefined——還沒有歷史;請使用T | undefined進行型別比較。 -
比較使用參考相等性——傳入基本型別或經
useMemo穩定的物件,否則渲染階段的守衛會永遠觸發,而 React 會以「Too many re-renders」阻止你。 -
先前的值與閉包中的目前值是不同的問題——需要比較時使用
usePrevious;需要 stale callback 時使用useLatest。
從 @reactuses/core 取得它,讓「previous」真正代表「之前」。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.