---
title: "為什麼同一個值要存一份 state、又存一份 ref？latest ref pattern 與值的兩種時間語意"
description: "從 stale closure 出發拆解 React 的 latest ref pattern：為什麼同一個值需要 state、ref 兩份，寫入 ref 時機的完整比較，以及官方 useEffectEvent 的定位與限制"
lang: "zh-Hant-TW"
tags: ["React", "hooks", "useRef", "useEffectEvent", "closure"]
pubDate: 2026-07-16T00:00:00.000Z
updatedDate: 2026-07-17T00:00:00.000Z
---

同一個值同時存一份 state、一份 ref，看起來像冗餘，其實是 React 生態裡行之有年的 pattern。這篇從 stale closure 問題出發，講清楚為什麼需要兩份值、什麼時候用、以及最容易被講錯的細節：該在哪裡寫入 ref。

```jsx
function Chat({ roomId }) {
  const [text, setText] = useState("");

  const textRef = useRef(text);
  textRef.current = text; // 每次 render 都把最新值寫進 ref（寫入位置的取捨見後文）

  useEffect(() => {
    const socket = connect(roomId);
    socket.on("ping", () => {
      socket.send(textRef.current); // 讀 ref → 呼叫當下的最新值
    });
    return () => socket.disconnect();
  }, [roomId]); // text 不需要在 deps 裡
}
```

> [!NOTE]
> React 19.2 起，effect 這個情境可以直接用官方的 [`useEffectEvent`](https://react.dev/reference/react/useEffectEvent)（見後文）。但它只覆蓋這個 pattern 的一部分用途，而且限制不少。先懂底層的 latest ref pattern，才看得懂它為什麼存在、那些限制在防什麼，也才知道撞上限制（或專案還沒升上 19.2）時，退路該手寫成什麼樣。

## 問題的根源：誕生時間 ≠ 執行時間

兩個基本事實：

第一，在 JavaScript 裡，一個函式執行時在它內部建立的 function，會記住誕生那一刻的外層作用域，執行時回去那裡查變數，這就是 closure。

第二，function component 本身就是一個函式，每次 render 時 React 都會重新呼叫它一次，所以裡面的 `const [text] = useState(...)` 每次都會產生該次 render 專屬的 `text` 變數。

兩者相加：該次 render 中讀了 state 的任何 closure，捕捉到的都是 render 執行那一刻的 state 變數。

這是 React 刻意選擇的設計，官方叫它「[state as a snapshot](https://react.dev/learn/state-as-a-snapshot)」：一次 render 內所有地方看到的 state 都保證一致，連在 render 中誕生、卻不在 render 中執行的 function（例如 effect）也一樣，這也是 concurrent rendering 能安全運作的基礎。

問題出在一個特殊情境：

某次 render（或其 effect）中建立、透過 closure 參照了 state 的 function，被交給了不歸 React render 週期管的地方執行，例如 socket 的 listener 表、timer queue、第三方 library 內部。這件事本身沒問題，closure 的行為完全正確、也符合 React 的設計理念。但**有時，我們要的是執行當下的值——而透過 closure 讀 state，查到的永遠是誕生時的值**。這個落差，就是官方文件中說的 [stale closure](https://react.dev/reference/eslint-plugin-react-hooks/lints/exhaustive-deps)。

## 三個解法，ref 是其中之一

執行那一刻，新值只可能從三個地方來：一份新誕生的 closure、執行者遞進來的參數、或一個執行時查得到最新值的穩定位置。對應到三個解法：

**讓 function 跟著 render 換新**：值變了，就把交出去的 function 撤回、換上新誕生的一份：退訂舊 listener、清掉舊 timer、向第三方 library 重新註冊。在 React 裡就是 effect 的 cleanup＋deps：把 `text` 放進 deps，值變了 effect 重跑、重新訂閱。語意最乾淨，也是預設該走的路。但要考慮的是換新的成本。如果換新意味著斷線重連一次 socket，每打一個字就重連，代價就不成比例。這種困境正是官方文件〈[Separating Events from Effects](https://react.dev/learn/separating-events-from-effects)〉開頭的例子。

**讓值當參數遞進來**：執行者呼叫 function 時把最新值一併交給它，function 就不需要透過 closure 拿值。事件系統天生這樣做：event handler 讀 `event.target.value`、socket callback 讀 message payload。至於 React state，更新 API 也都做成這個形狀：`setText(prev => ...)` 的 [updater form](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state)、[`useReducer`](https://react.dev/reference/react/useReducer) 的 reducer 會在執行時收到最新 state，連 ref 都不用。限制也在這：遞什麼是執行者說了算：事件只帶自己的 payload，React 只在「算下一個 state」時遞 state，要拿最新 state 去做別的事（送出、呼叫 API），這條路就幫不上忙。

**讓 function 執行時去穩定位置查值**：function 不換，closure 行為也不變，變的是讓它捕捉的東西：不是每次 render 換新的 state 變數，而是一個跨 render 永遠是同一個物件的 mutable 容器，執行時才去讀內容。

能當這個穩定位置的不只一種：外部 store（Redux／Zustand 的 `getState()`）、module 層級變數都算。而用 React 內建的 API，慣用寫法就是本篇的主角 latest ref pattern：容器由 `useRef` 提供（[它每次 render 回傳同一個物件](https://react.dev/reference/react/useRef)），function 執行時讀 `.current`，取到的值就從誕生時的值變成執行當下的值。

這條路共同的代價是：React 的自動同步只涵蓋「state → 畫面」，所以穩定位置內的值內容什麼時候更新、跟 state 一不一致，都要靠自己維護。以 latest ref pattern 來說，就是同一個值有兩份：state 那份與畫面的同步由 React 負責，`.current` 那份與 state 的同步由我們自己手動維護。而手動維護的寫入時機很容易做錯，我們會在後文詳述。

## 技術本質：同一個值的兩種時間語義

回頭看開頭的程式碼：

```jsx
function Chat({ roomId }) {
  const [text, setText] = useState("");

  const textRef = useRef(text);
  textRef.current = text; // 每次 render 都把最新值寫進 ref

  useEffect(() => {
    const socket = connect(roomId);
    socket.on("ping", () => {
      socket.send(textRef.current); // 讀 ref → 呼叫當下的最新值
    });
    return () => socket.disconnect();
  }, [roomId]); // text 不需要在 deps 裡
}
```

同一個 `text` 存了兩份，看起來像冗餘。但拆開看會發現，`text` 同時有兩種讀者，而兩種讀者要的是**不同時間點的值**：

**畫面要的是 render 那一刻的值**（pull-at-render-time）。一次 render 內所有讀取必須一致，畫面才不會有一部分新、一部分舊。對應這種需求的存法是 state：值凍結成該次 render 的快照，要改變就觸發 re-render、整個畫面一起換新。

**長壽命 function 要的是被呼叫那一刻的值**（pull-at-call-time）。它誕生在某次 render（或其 effect）、活得比那次 render 久，執行時需要當下最新的內容。對應這種需求的存法是 ref：一樣靠 closure 捕捉，但容器跨 render 永遠是同一個物件，誕生在哪一代不再重要；內容隨時可換，[寫入也不觸發 re-render](https://react.dev/reference/react/useRef)，什麼時候讀 `.current`，就拿到什麼時候的內容。

關鍵在於：只用一種存法，滿足不了兩種讀者。存成該次 render 執行環境裡的快照變數，呼叫時就讀不到最新；存成跨 render 的可變容器，render 內就失去一致的快照。兩種時間語意互斥，一種存法只能選一邊。所以當畫面和「誕生在 render 裡、卻活得比 render 久」的 function 同時需要 `text`，唯一的辦法就是兩種都存、各服務一邊，再靠每次 render 把最新的 state 寫進 ref，讓兩份不脫鉤。

## 關鍵細節：在哪裡寫入 ref

上一節說，兩份值靠「每次 render 把 state 寫進 ref」不脫鉤。那這行寫入該放在哪裡？這是這個 pattern 最容易被講錯的地方，常見寫法有幾種：

```jsx
const textRef = useRef(text);

// 寫法一：render 期間寫入
textRef.current = text;

// 寫法二三四：各種 effect 裡寫入
useEffect(() => {
  textRef.current = text;
});
useLayoutEffect(() => {
  textRef.current = text;
});
useInsertionEffect(() => {
  textRef.current = text;
});
```

先看官方的立場，不只一句 caveat：[`useRef` 文件明白寫著](https://react.dev/reference/react/useRef#caveats)「不要在 render 期間讀寫 `ref.current`（初始化除外），這會讓元件行為不可預測」；官方文件唯一示範過這個 pattern 的地方（[legacy Hooks FAQ](https://legacy.reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback)），一律把寫入放在 `useEffect` 裡，並附上「我們不推薦這個 pattern，僅為完整性列出」；新的 [`refs` lint 規則](https://react.dev/reference/eslint-plugin-react-hooks/lints/refs)更把 render 期間寫入幾乎逐字列為錯誤範例（唯一豁免是 lazy initialization），React Compiler 偵測到違規時會[放棄最佳化該元件](https://react.dev/learn/react-compiler/debugging)。所以官方立場一致：render 寫入違規，寫在 effect 裡才合規。

我的偏好和常見教學都不同：**四種寫法的合理排序是 `useInsertionEffect` > render 寫入 ≥ `useLayoutEffect` > `useEffect`**，我認為最好的選項是幾乎沒人用的 `useInsertionEffect`，其次是官方明文反對的 render 寫入，最常被教的兩種反而會被我排在後面。

以下是論證，先從官方理由的性質講起。文件從未舉出這種 idempotent 寫入（值完全由當次 render 輸入決定）在現行 React 下會出錯的實例；`useRef` 文件也承認，元件**可能仍然能動**，但 React 正在加的新功能會依賴這些預期。禁令的實質根據是為未來保留的純度契約，不是現行的 bug；生態系也確實分裂：react-use 和 ahooks 的 `useLatest` [就是在 render 期間寫](https://github.com/streamich/react-use/blob/master/src/useLatest.ts)、行之有年，Radix 和 floating-ui 則守規則寫在 effect 裡。

既然禁令防的是未來而不是現在，兩種寫法的取捨就可以從行為面來分析。不管把這行寫入放在哪，「ref 的內容」和「committed 的畫面」之間都可能出現一段不一致的區間，差別在三件事：往哪個**方向**不一致、這段區間**在什麼條件下存在**、區間內**誰讀得到**：

| 寫法                        | 方向             | 不一致區間何時存在                                                                                          | 區間內誰讀得到                                                       |
| --------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| render 期間寫入             | ref **超前**畫面 | 只在 concurrent render 被切片、暫停或丟棄時（見下）；同步 render 從寫入到 commit 一氣呵成                   | 恰好在該次 render 完成前觸發的外部 callback                          |
| `useEffect` 裡寫入          | ref **落後**畫面 | 每次 render 都有：commit、paint 之後才補寫                                                                  | 這段期間的**所有**外部事件，如 socket、使用者輸入、timer             |
| `useLayoutEffect` 裡寫入    | ref **落後**畫面 | 每次 render 都有，但整段在 commit 內同步完成                                                                | 外部事件插不進來，只剩排在它之前的 layout effect（子元件先於父元件） |
| `useInsertionEffect` 裡寫入 | ref **落後**畫面 | 每次 render 都有，在[所有 layout effect 之前](https://react.dev/reference/react/useInsertionEffect)同步完成 | 幾乎只剩其他 insertion effect                                        |

> [!NOTE]
> 表格裡的行為都可以用真實的 React 19 重現，可執行的實驗程式碼在 [latest-ref-pattern-experiments](https://github.com/YuCJ/latest-ref-pattern-experiments)。

**寫在 effect 裡的三種版本，落後區間都無條件出現；而 `useEffect` 版開出的區間，正好是實務上最常會來讀值的時段。** `useEffect` 版每次 render 都開一段跨 task 的不一致區間：畫面已更新、ref 還是舊值，而 socket message、使用者互動、timer 全都插得進來，而 latest ref 的讀者本來就是這些外部非同步事件，它們絕大多數觸發在 paint 之後的穩定期，正好整段落在這段區間裡，高頻事件下是真實會踩到的 bug。

此外 effect 有執行順序（一律子元件先於父元件、同元件按宣告順序），任何排在「寫入 ref 的 effect」之前執行的同類 effect，在 setup 時讀 ref 也會讀到舊值。

**把寫入往 commit 前段挪，能縮小落後的區間，但消不掉順序破口。** `useLayoutEffect` 把這段區間壓進 commit 的同步流程，外部事件插不進來，於是順序成了僅剩的破口：subscription（layout）effect 若在 setup 時讀 ref、而寫入 ref 的那行宣告在它後面，就讀到舊值，搬動一行 hook 就壞，lint 不會警告。

`useInsertionEffect` 再把寫入挪到所有 layout effect 之前。insertion effect 彼此仍有先後，但實務上幾乎沒有人在 insertion effect 裡讀這種 ref，殘餘風險最小，是 effect 寫入這一類裡最小的不一致區間。整體看，effect 寫入是用規則層面的純度，換一段**每次 render 必然存在**的落後區間。

**render 期間寫入的超前區間有條件才存在，但一旦存在，長度沒有上限。** 同步 render（一般的 setState 更新）下，從元件函式執行（寫入 ref）到 commit 是一氣呵成的同步流程，中間插不進任何事件，不一致的區間實際上不存在。

不一致的區間只出現在 concurrent render：[React 18 起 concurrent rendering 是 opt-in 的](https://react.dev/blog/2022/03/29/react-v18)，只有用到 concurrent 功能的更新（`startTransition`、`useDeferredValue`、Suspense、Activity 預渲染）會以可中斷的方式 render。這種 render 會讓出主執行緒、可能中途暫停或整個被丟棄，「元件函式已執行（ref 已寫）、樹還沒 commit」的狀態因此可以橫跨多個 task，期間觸發的事件就讀得到超前值；若該次 render 中途 suspend 或被丟棄，超前狀態甚至會留到下一次真正 commit 的 render 重寫它為止。

所以單次比較，render 寫入的不一致區間可以比 effect 寫入的任何一種都長；但它需要「用了 concurrent 功能」＋「render 恰好跨 task」＋「事件恰好在這段觸發」三個條件疊加，整體風險遠小於 `useEffect`、`useLayoutEffect` 版每次 render 必然打開、又有真實讀者會踩進去的區間。

**即使不一致真的發生，兩個方向的嚴重程度也不對等。** 落後的值對這個 pattern 的讀者是語意上必錯的，它重演了 stale closure，正是這個 pattern 要解的 bug 本身。超前的值則來自同一條更新佇列，是「即將顯示的最新值」，對「要呼叫當下最新值」的讀者（送出使用者剛打的字、記錄最新狀態）語意通常仍然成立；真正需要「與畫面嚴格一致」的讀者，該用的是 closure 快照（本篇的第一種讀者），不會來讀 ref。

Dan Abramov [點名過超前的風險](https://github.com/reactjs/rfcs/pull/220#issuecomment-1118027152)，指出 transition 進行中 event handler 會「在新畫面顯示之前就用到它的資料」，但同一則留言接著承認 layout effect 版有鏡像缺陷，「所以沒有好的 polyfill 方式」；至今也沒有人發表過超前造成可觀察 bug 的重現。網路上常被引用的「concurrent 下 ref 出錯」示範，用的都是 `count.current++` 這種非 idempotent 寫入，是另一種違規。

**render 期間寫入的前提，是一條 React 不會代為把關的紀律：寫入必須 idempotent，也就是重複執行結果相同。** 具體說，寫進 ref 的只能是當次 render 輸入（props、state）的純推導；不能累加（`count.current++`）、不能摻時間戳或亂數、不能拿 ref 的舊值算新值。需要這條紀律，是因為 React 保留把 render 重跑或丟棄的權利（StrictMode 雙跑、time slicing 重來、被丟棄的 transition、Activity 預渲染），寫在 render 裡的東西必須經得起重跑。

「把 state 原樣寫進 ref」天生滿足這個條件，紀律的重點是別把其他種類的寫入也順手放進 render。寫在 effect 裡則不需要這條紀律（effect 每次 commit 保證只跑一次），但守住它也救不了 effect 寫入的落後區間。`useRef` 文件的警告真正防的是「render 結果依賴 ref」這類破壞純度的用法；這種寫入不影響任何 render 輸出。

把上面的分析收攏成排序：**`useInsertionEffect` > render 寫入 ≥ `useLayoutEffect` > `useEffect`**。

**首選 `useInsertionEffect`**：它的語意正好等於下一節內建 API 選的語意（永遠讀到最新的 **committed** 值，不超前也不落後於畫面），曝險趨近於零，又不違反純度契約，lint 和 Compiler 也不會報錯。代價是要多包一個自訂 hook、需要 React 18+；嚴格說它也是超出設計用途的用法（[官方文件說](https://react.dev/reference/react/useInsertionEffect)這個 hook 是給 CSS-in-JS library 作者用的），但它依賴的是文件明載的時序保證，不是違反文件明載的禁令，像 floating-ui 的 `useEffectEvent` shim 就是這樣寫的。

**render 期間寫入是務實的第二名**：兩行、零依賴、不用管 effect 順序、永不落後，適合不想為此引入自訂 hook 的場合。代價是 lint 報錯（得 `eslint-disable` 並註明理由）、React Compiler 跳過該元件的自動 memoization，以及 concurrent render 下可能拉得很長的超前區間；選它就要守住前面說的 idempotent 紀律。

`useLayoutEffect` 優點是符合 React 官方的規則，但缺點是不能保證其他 component 的 `useLayoutEffect` 中讀到的是最新的值；

`useEffect` 版不一致的區間最大，如果我自己選一定會盡量避免。（雖然 React 官方文件曾經這樣寫）

## 官方 API：useEffectEvent

React 19.2 出了一個正式 API：[`useEffectEvent`](https://react.dev/reference/react/useEffectEvent)。不過先把範圍講清楚：官方並沒有把整個「兩種時間語意」的需求做成 API，它給 `useEffectEvent` 的定位窄得多，只處理 **effect 這個情境**。

回頭看第一個解法（cleanup＋deps 讓 function 跟著 render 換新）留下的困境：`text` 進了 deps，值一變 effect 就整個重跑、socket 重連；不進 deps，closure 又永遠讀到舊值。官方把這個困境描述成「effect 裡混進了不該反應式的邏輯」：需求只是在 effect 的邏輯跑起來時**讀**某個值，並不希望那個值的變化**觸發** effect 重新同步。

`useEffectEvent` 就是把這段邏輯抽出來的工具：用它包起來的函式「永遠看得到最新的 props 和 state，卻不會讓 effect 重新同步」，也不列入 deps：

```jsx
function Chat({ roomId }) {
  const [text, setText] = useState("");

  const onPing = useEffectEvent((socket) => {
    socket.send(text); // 永遠讀到最新的 text
  });

  useEffect(() => {
    const socket = connect(roomId);
    socket.on("ping", () => onPing(socket));
    return () => socket.disconnect();
  }, [roomId]);
}
```

**「只能在 effect 裡用」不是還沒做完，是刻意畫下的邊界。** [文件明說](https://react.dev/reference/react/useEffectEvent#caveats) Effect Event 概念上屬於某一個特定的 effect，**不是一個通用的「退出 reactivity」API**：只能在 effect（或其他 Effect Event）內呼叫，不能在 render 期間呼叫、不能傳給子元件或其他 hook。強制這個邊界的是 [`rules-of-hooks` 內建的檢查](https://github.com/react/react/blob/main/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts)：它記下元件裡哪些變數綁定了 `useEffectEvent` 的回傳值，之後這些變數只要出現在 effect 之外（不管是被呼叫、被賦值、還是當 prop 寫進 JSX），一律報錯，錯誤訊息把邊界講得很直白：「只能從**同一個元件**的 Effect 和 Effect Event 呼叫，不能賦值給變數或往下傳」。（「不能放進 deps」那條則由 `exhaustive-deps` 負責。）

連傳都不行的理由有兩層。表層是可驗證性：上面的檢查是定義元件內的靜態分析，函式一旦當成 prop 離開元件，接收方眼裡它就是普通函式，lint 在那裡沒有任何資訊可查，可能被放進 deps、可能在 render 期間被呼叫，規則形同虛設，所以乾脆禁止離開。

深層是 [React 團隊擱置原始提案時的判斷](https://github.com/reactjs/rfcs/pull/220#issuecomment-1259938816)：把值包進 Effect Event 等於抹掉它的 reactivity，而「該不該抹」只有最終呼叫它的那個 effect 有資格判斷，讓非反應式函式在元件樹裡流動，等於替所有下游做了它們沒同意的決定。

**連回傳函式的 identity 都刻意做成每次 render 不同。** 官方的解釋很直白：這是一種 runtime assertion：如果誤把它當一般 callback、依賴了它的 identity（拿去 memo、放進 deps），effect 會每次 render 都重跑，讓誤用立刻現形，而不是安靜地看起來能動。

**時序上，它做到了上一節整個排序在逼近的理想。** 文件寫明它讀到的是呼叫當下「最新的 **committed** 值」，不超前也不落後，跟 committed 的畫面完全同步。做法是把 `.current` 的切換做在 commit 內部、**所有** layout effect 之前、**跨所有元件一次完成**。

上一節說連第一名的 `useInsertionEffect` 也只是逼近，差距就在最後一項「跨所有元件一次完成」。[useEvent RFC](https://github.com/reactjs/rfcs/pull/220)（`useEffectEvent` 的前身）裡，React 團隊直接承認**「高擬真的 polyfill 不可能存在，因為 React 沒有任何 lifecycle 或 hook 能在正確的時機切換 `.current`」**——這不是功力問題，是先天限制：不管用哪種 effect 同步，effect 都是逐元件依序執行的。

RFC 點名的例子是 `useLayoutEffect` 寫法跨元件的落後區間：子元件的 layout effect 先於父元件執行，若它呼叫父元件傳下來的 handler，此時父元件寫入 ref 的 effect 還沒跑，讀到的是父元件上一次 render 的 state。雖然 RFC 舉的這一種不一致 `useInsertionEffect` 同樣消得掉（全樹的 insertion effect 都跑在任何 layout effect 之前），但 `useInsertionEffect` 消不掉 insertion effect 彼此之間同樣逐元件執行的殘餘區間。內建版則沒有任何公開 API 可能觸及的殘餘區間。

所以，effect 情境內能用它就用它（升上 React 19.2 時記得同步升級 `eslint-plugin-react-hooks`，lint 才認得被包起來的函式不必進 deps）。

但要認清它只覆蓋 latest ref pattern 用途的一部分，三種情況仍然需要手寫：**專案還沒升到 19.2**；**呼叫位置不在 effect 裡**，例如 event handler 裡 `await` 之後要讀最新值、debounce／throttle 從 handler 啟動的 timer callback、[`useImperativeHandle`](https://react.dev/reference/react/useImperativeHandle) 暴露給父元件的方法，這些呼叫路徑都不是從 effect 出發，Effect Event 的規則不允許；**需求是一個「identity 穩定、可以傳給子元件」的 callback**，官方刻意不支援，而 [legacy FAQ 示範這個 pattern 用的正是這種場景](https://legacy.reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback)（`useCallback` 包的穩定 `handleSubmit`）。遇到這些，就回到上一節的結論，手動維護寫入時機。
