HeyBinyang

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

·技術·visibility1 閱讀 ·React

前面幾篇已經把幾件事講清楚了:

  • 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。

五、一個最容易寫對也最容易寫歪的例子:上一個值

很多教程都喜歡用 usePrevioususeRef 示例,因為它確實很典型:

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 條規則

  1. useRef 是一個跨渲染持久存在的可變盒子,改它不會觸發重渲染。

  2. 它適合保存「不影響 UI、但需要記住」的值,比如 DOM 節點、計時器 ID、第三方實例、最新回調。

  3. 如果某個值變化後應該立刻反映到界面上,那它應該是 state,不是 ref。

  4. stale closure 場景下,ref 常用來讓「長壽命回調」讀到最新值,而不必頻繁重建訂閱。

  5. 不要在 render 期間隨意讀寫 ref.current,這會破壞 render 的純度。

  6. 不要用 ref 去逃避本來就應該存在的 effect 依賴。

  7. useRefuseCallback 經常搭配,但一個解決「值持久化」,一個解決「函數引用穩定」,別混為一談。

分享