reactuse.com

React usePrevious Hook:追蹤先前的 State 與 Props (2026)

React 讓你在每次渲染時都能取得 state 與 props 的目前值,卻沒有內建的方式可以查詢「之前」的值。因此大家都會複製同一段十行左右的寫法:把值存到 ref 裡面,放在 useEffect 中更新,最後回傳 ref.current。這個方法在示範時沒問題,也能部署到正式環境,但有一天「count 上升 ↑」的指示器卻開始宣稱值沒有改變——那是因為元件因為其他完全無關的原因重新渲染,而 ref 悄悄地覆寫了自己的歷史紀錄。

來自 @reactuses/coreusePrevious 追蹤的是先前的「不同值」,而非前一次「渲染」的值。它採用 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

點擊 +1count 變成 5,prevCount 是 4,指示器顯示「↑ went up」。正確。接著點擊 toggle themecount 沒有改變,但元件重新渲染,effect 再次執行,ref 變成 5。下一次渲染時 prevCount === count,指示器消失——元件現在認為 count 從未改變。任何來自父層的重新渲染、context 更新,或是兄弟元件的 state 改變,都會造成同樣的結果。你建立這個 hook 所要做的比較,恰恰就是 hook 本身破壞的。

這不是假設情境:同樣的問題也出現在 react-useahooks 以及 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。 審計樣式的表單和設定面板,顯示哪些編輯正在等待中——並排渲染 prevvalue;在第一次渲染時 prevundefined,因此不顯示任何內容。

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」真正代表「之前」。