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。那時 count0,於是它在那個閉包裡永遠是 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 裡寫入它來保持其最新。這是這一族裡最有用的成員

只是為了讓閉包能讀到它、而不是為了讓 effect 重跑——useLatest 就是答案。

2. useEvent——身分穩定、又總能看到最新 state 的回呼

useLatest 解決的是透過穩定參考讀取一個useEvent 為一個函式解決同樣的問題

,身分在元件整個生命週期內凍結,但每次呼叫執行的都是你傳入的最新版本——把最新的 props 和 state 一併烤進去。

正是這個 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 兩者兼得

。如果這個名字眼熟,它跟 React 實驗性的 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 裡回傳當前掛載狀態,所以你可以在任何非同步回呼裡呼叫它而不必把它加進依賴陣列。它故意是函式而不是布林值

。對於 fetch,你往往可以更傾向用 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>;
}

首次渲染時 previousundefined(此前沒有渲染過),之後每次渲染它都持有上一次渲染的值。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 元件庫作者生活裡最繁瑣的樣板

、且通常沒正確處理回呼 ref 的那個手寫 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 就是這扇門,而且鉸鏈已經上好了油。