同一個值同時存一份 state、一份 ref,看起來像冗餘,其實是 React 生態裡行之有年的 pattern。這篇從 stale closure 問題出發,講清楚為什麼需要兩份值、什麼時候用、以及最容易被講錯的細節:該在哪裡寫入 ref。
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 裡}
React 19.2 起,effect 這個情境可以直接用官方的 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」:一次 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。
三個解法,ref 是其中之一
執行那一刻,新值只可能從三個地方來:一份新誕生的 closure、執行者遞進來的參數、或一個執行時查得到最新值的穩定位置。對應到三個解法:
讓 function 跟著 render 換新——值變了,就把交出去的 function 撤回、換上新誕生的一份:退訂舊 listener、清掉舊 timer、向第三方 library 重新註冊。在 React 裡就是 effect 的 cleanup+deps:把 text 放進 deps,值變了 effect 重跑、重新訂閱。語意最乾淨,也是預設該走的路。但要考慮的是換新的成本。如果換新意味著斷線重連一次 socket,每打一個字就重連,代價就不成比例——這種困境,正是官方文件〈Separating Events from Effects〉開頭的例子。
讓值當參數遞進來——執行者呼叫 function 時把最新值一併交給它,function 就不需要透過 closure 拿值。事件系統天生這樣做:event handler 讀 event.target.value、socket callback 讀 message payload。至於 React state,更新 API 也都做成這個形狀——setText(prev => ...) 的 updater form、useReducer 的 reducer 會在執行時把最新 state 遞給你,連 ref 都不用。限制也在這:遞什麼是執行者說了算——事件只帶自己的 payload,React 只在「算下一個 state」時遞 state,要拿最新 state 去做別的事(送出、呼叫 API),這條路就幫不上忙。
讓 function 執行時去穩定位置查值——function 不換,closure 行為也不變,變的是讓它捕捉的東西:不是每次 render 換新的 state 變數,而是一個跨 render 永遠是同一個物件的 mutable 容器,執行時才去讀內容。能當這個穩定位置的不只一種:外部 store(Redux/Zustand 的 getState())、module 層級變數都算。當值住在 React state 裡,慣用形就是本篇的主角 latest ref pattern:容器由 useRef 提供——它每次 render 回傳同一個物件——function 執行時才讀 .current,「取值」就從誕生時延後到了執行當下。這條路共同的代價是:React 的自動同步只涵蓋「state → 畫面」這條線,穩定位置住在這條線之外,它的內容什麼時候更新、跟 state 一不一致,都要靠自己維護。落到 latest ref pattern 上,就是同一個值有兩份——state 那份與畫面的同步由 React 負責,.current 那份與 state 的同步由你手動維護,而寫入的時機很容易做錯,我們會在後文詳述。
技術本質:同一個值的兩種時間語意
回頭看開頭的程式碼:
function Chat({ roomId }) {const [text, setText] = useState("");const textRef = useRef(text);textRef.current = text; // 每次 render 都把最新值鏡射進 refuseEffect(() => {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,什麼時候讀 .current,就拿到什麼時候的內容。
關鍵在於:只用一種存法,滿足不了兩種讀者。存成該次 render 執行環境裡的快照變數,呼叫時就讀不到最新;存成跨 render 的可變容器,render 內就失去一致的快照——兩種時間語意互斥,一種存法只能選一邊。所以當畫面和「誕生在 render 裡、卻活得比 render 久」的 function 同時需要 text,唯一的辦法就是兩種都存、各服務一邊,再靠每次 render 的鏡射讓兩份不脫鉤。
關鍵細節:在哪裡寫入 ref
上一節說,兩份值靠「每次 render 的鏡射」不脫鉤。那鏡射該寫在哪裡?這是這個 pattern 最容易被講錯的地方,你會看到兩種寫法:
// A:render 期間寫入const textRef = useRef(text);textRef.current = text;// B:effect 裡同步const textRef = useRef(text);useLayoutEffect(() => {textRef.current = text;});
先把官方立場攤開,它不只一句 caveat:useRef 文件明白寫著「不要在 render 期間讀寫 ref.current(初始化除外),這會讓元件行為不可預測」;官方文件唯一示範過這個 pattern 的地方——legacy Hooks FAQ——一律把寫入放在 useEffect 裡,並附上「我們不推薦這個 pattern,僅為完整性列出」;新的 refs lint 規則更把 A 幾乎逐字列為錯誤範例(唯一豁免是 lazy initialization),React Compiler 偵測到違規時會放棄最佳化該元件。所以官方立場一致:A 違規,B 才是官方認可的寫法。
本篇的立場和常見教學都不同:四種寫法的合理排序是 useInsertionEffect ≥ A > useLayoutEffect > useEffect——最好的選項是幾乎沒人用的 useInsertionEffect,其次是官方明文反對的 A,最常被教的兩種反而墊底。以下是論證,先從官方理由的性質講起。文件從未舉出這種 idempotent 寫入(值完全由當次 render 輸入決定)在現行 React 下會出錯的實例,useRef 文件自己承認「你的元件可能仍然能動,但我們正在加的新功能會依賴這些預期」——禁令的實質根據是為未來保留的純度契約,不是現行的 bug;生態系也確實分裂:react-use 和 ahooks 的 useLatest 就是在 render 期間寫、行之有年,Radix 和 floating-ui 則守規則寫在 effect 裡。既然禁令防的是未來而不是現在,兩種寫法的取捨就可以攤開用行為分析。不管把鏡射寫在哪,「ref 的內容」和「committed 的畫面」之間都可能出現不一致的空窗,差別在三件事:往哪個方向不一致、空窗在什麼條件下存在、空窗期間誰讀得到:
| 寫法 | 方向 | 空窗何時存在 | 空窗期間誰讀得到 |
|---|---|---|---|
| A:render 期間寫入 | ref 超前畫面 | 只在 concurrent render 被切片、暫停或丟棄時(見下);同步 render 從寫入到 commit 一氣呵成 | 恰好在該次 render 完成前觸發的外部 callback |
B:useEffect | ref 落後畫面 | 每次 render 都有:commit、paint 之後才補寫 | 這段期間的所有外部事件——socket、使用者輸入、timer |
B:useLayoutEffect | ref 落後畫面 | 每次 render 都有,但整段在 commit 內同步完成 | 外部事件插不進來,只剩排在它之前的 layout effect(子元件先於父元件) |
B:useInsertionEffect | ref 落後畫面 | 每次 render 都有,在所有 layout effect 之前同步完成 | 幾乎只剩其他 insertion effect |
B 的落後空窗無條件出現,而且主要讀者就住在裡面。 useEffect 版每次 render 都開一段跨 task 的空窗:畫面已更新、ref 還是舊值,而 socket message、使用者互動、timer 全都插得進來——latest ref 的讀者本來就是這些外部非同步事件,它們絕大多數觸發在 paint 之後的穩定期,正好整段落在空窗裡,高頻事件下是真實會踩到的 bug。此外 effect 有執行順序(一律子元件先於父元件、同元件按宣告順序),任何排在鏡射之前執行的同類 effect,在 setup 時讀 ref 也會讀到舊值。
把鏡射往 commit 前段挪,能縮小 B 的空窗,但消不掉順序破口。 useLayoutEffect 把空窗壓進 commit 的同步區段,外部事件插不進來,於是順序成了僅剩的破口:你的 subscription(layout)effect 若在 setup 時讀 ref、而鏡射那行宣告在它後面,就讀到舊值——搬動一行 hook 就壞,lint 不會警告。useInsertionEffect 再把鏡射挪到所有 layout effect 之前——insertion effect 彼此仍有先後,但實務上幾乎沒有人在 insertion effect 裡讀這種 ref,殘餘曝險最小,是 B 系裡最小的空窗。整體看,B 是用規則層面的純度,換一段每次 render 必然存在的落後空窗。
A 的超前空窗有條件才存在——但一旦存在,長度沒有上限。 同步 render(一般的 setState 更新)下,從元件函式執行(寫入 ref)到 commit 是一氣呵成的同步流程,中間插不進任何事件,空窗實際上不存在。空窗只出現在 concurrent render:React 18 起 concurrent rendering 是 opt-in 的,只有用到 concurrent 功能的更新(startTransition、useDeferredValue、Suspense、Activity 預渲染)會以可中斷的方式 render——這種 render 會讓出主執行緒、可能中途暫停或整個被丟棄,「元件函式已執行(ref 已寫)、樹還沒 commit」的狀態因此可以橫跨多個 task,期間觸發的事件就讀得到超前值;若該次 render 中途 suspend 或被丟棄,超前狀態甚至會留到下一次真正 commit 的 render 重寫它為止。所以單次比較,A 的空窗可以比 B 的任何一種都長;但它需要「用了 concurrent 功能」+「render 恰好跨 task」+「事件恰好在這段觸發」三個條件疊加,總曝險遠小於 B 每次 render 的必然空窗。
即使空窗出現,兩個方向的嚴重程度也不對等。 落後的值對這個 pattern 的讀者是語意上必錯的——它重演了 stale closure,正是這個 pattern 要解的 bug 本身。超前的值則來自同一條更新佇列,是「即將顯示的最新值」,對「要呼叫當下最新值」的讀者(送出使用者剛打的字、記錄最新狀態)語意通常仍然成立;真正需要「與畫面嚴格一致」的讀者,該用的是 closure 快照(本篇的第一種讀者),不會來讀 ref。Dan Abramov 點名過超前的風險——transition 進行中 event handler 會「在新畫面顯示之前就用到它的資料」——但同一則留言接著承認 layout effect 版有鏡像缺陷,「所以沒有好的 polyfill 方式」;至今也沒有人發表過超前造成可觀察 bug 的重現——網路上常被引用的「concurrent 下 ref 出錯」示範,用的都是 count.current++ 這種非 idempotent 寫入,是另一種違規。
A 的入場費,是一條 React 不會替你把關的紀律:寫入必須 idempotent——重複執行,結果相同。 具體說,寫進 ref 的只能是當次 render 輸入(props、state)的純推導;不能累加(count.current++)、不能摻時間戳或亂數、不能拿 ref 的舊值算新值。需要這條紀律,是因為 React 保留把 render 重跑或丟棄的權利(StrictMode 雙跑、time slicing 重來、被丟棄的 transition、Activity 預渲染),寫在 render 裡的東西必須經得起重跑——鏡射本身天生滿足,紀律的重點是別把其他種類的寫入也順手放進 render。B 不需要這條紀律(effect 每次 commit 保證只跑一次),但守住它也救不了 B 的落後空窗。useRef 文件的警告真正防的是「render 結果依賴 ref」這類破壞純度的用法;鏡射寫入不影響任何 render 輸出。
把上面的分析收攏成排序:useInsertionEffect ≥ A > useLayoutEffect > useEffect。
首選 useInsertionEffect:它的語意正好等於下一節內建 API 選的語意——永遠讀到最新的 committed 值,不超前也不落後於畫面——曝險趨近於零,又不違反純度契約,lint 和 Compiler 都不會找你麻煩。代價是要多包一個自訂 hook、需要 React 18+;嚴格說它也是超出設計用途的用法(官方文件說這個 hook 是給 CSS-in-JS library 作者用的),但它依賴的是文件明載的時序保證,不是違反文件明載的禁令——floating-ui 的 useEffectEvent shim 就是這樣寫的。
A 是務實的第二名:兩行、零依賴、不用管 effect 順序、永不落後,適合不想為此引入自訂 hook 的場合。代價是 lint 報錯(得 eslint-disable 並註明理由)、React Compiler 跳過該元件的自動 memoization,以及 concurrent render 下可能拉得很長的超前空窗;選它就要守住前面說的 idempotent 紀律。useLayoutEffect 再次之——守約,但留著會咬到真實消費者的順序破口;useEffect 版空窗最大,盡量避免。
不過連第一名也只是逼近——下一節會看到,理想的切換時機藏在 commit 內部,公開 API 貼得近、複製不了。
官方 API:useEffectEvent
React 19.2 出了一個正式 API:useEffectEvent。不過先把範圍講清楚:官方並沒有把整個「兩種時間語意」的需求做成 API,它給 useEffectEvent 的定位窄得多——只處理 effect 這個情境。回頭看第一個解法(cleanup+deps 讓 function 跟著 render 換新)留下的困境:text 進了 deps,值一變 effect 就整個重跑、socket 重連;不進 deps,closure 又永遠讀到舊值。官方把這個困境描述成「effect 裡混進了不該反應式的邏輯」——你只想在 effect 的邏輯跑起來時讀某個值,並不想讓那個值的變化觸發 effect 重新同步。useEffectEvent 就是把這段邏輯抽出來的工具:用它包起來的函式「永遠看得到最新的 props 和 state,卻不會讓 effect 重新同步」,也不列入 deps:
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 裡用」不是還沒做完,是刻意畫下的邊界。 文件明說 Effect Event 概念上屬於某一個特定的 effect,不是一個通用的「退出 reactivity」API:只能在 effect(或其他 Effect Event)內呼叫,不能在 render 期間呼叫、不能傳給子元件或其他 hook。強制這個邊界的是 rules-of-hooks 內建的檢查:它記下元件裡哪些變數綁定了 useEffectEvent 的回傳值,之後這些變數只要出現在 effect 之外——不管是被呼叫、被賦值、還是當 prop 寫進 JSX——一律報錯,錯誤訊息把邊界講得很直白:「只能從同一個元件的 Effect 和 Effect Event 呼叫,不能賦值給變數或往下傳」。(「不能放進 deps」那條則由 exhaustive-deps 負責。)
連傳都不行的理由有兩層。表層是可驗證性:上面的檢查是定義元件內的靜態分析,函式一旦當 prop 離開元件,接收方眼裡它就是普通函式,lint 在那裡沒有任何資訊可查——可能被放進 deps、可能在 render 期間被呼叫,規則形同虛設,所以乾脆禁止離開。深層是 React 團隊擱置原始提案時的判斷——把值包進 Effect Event 等於抹掉它的 reactivity,而「該不該抹」只有最終呼叫它的那個 effect 有資格判斷,讓非反應式函式在元件樹裡流動,等於替所有下游做了它們沒同意的決定。
連回傳函式的 identity 都刻意做成每次 render 不同。 官方的解釋很直白:這是一種 runtime assertion——如果你誤把它當一般 callback、依賴了它的 identity(拿去 memo、放進 deps),effect 會每次 render 都重跑,讓誤用立刻現形,而不是安靜地看起來能動。
時序上,它做到了上一節整個排序在逼近的理想。 文件寫明它讀到的是呼叫當下「最新的 committed 值」——不超前也不落後,跟 committed 的畫面完全同步。做法是把 .current 的切換做在 commit 內部、所有 layout effect 之前、跨所有元件一次完成。
上一節說連第一名的 useInsertionEffect 也只是逼近,差距就在最後一項「跨所有元件一次完成」。useEvent RFC(useEffectEvent 的前身)裡,React 團隊直接承認**「高擬真的 polyfill 不可能存在,因為 React 沒有任何 lifecycle 或 hook 能在正確的時機切換 .current」**——這不是功力問題,是先天限制:不管用哪種 effect 同步,effect 都是逐元件依序執行的。RFC 點名的例子是 useLayoutEffect 寫法的跨元件落後空窗——子元件的 layout effect 先於父元件執行,若它呼叫父元件傳下來的 handler,此時父元件的鏡射還沒跑,讀到的是父元件上一次 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 暴露給父元件的方法,這些呼叫路徑都不是從 effect 出發,Effect Event 的規則不允許;你要的是一個「identity 穩定、可以傳給子元件」的 callback——官方刻意不支援,而 legacy FAQ 示範這個 pattern 用的正是這種場景(useCallback 包的穩定 handleSubmit)。遇到這些,就回到上一節的結論,自己維護寫入時機。
延伸主題
- Signals——另一派 reactivity 模型的對照,把本篇的預設整個反過來。signal 的值只有一份,住在跨 render 的可變容器裡,讀取天生是 pull-at-call-time:什麼時候讀就拿到什麼時候的內容,「最新值」不需要任何 pattern。但別誤讀成「兩種時間語意的需求消失了」——快照語意的需求照樣存在(async handler 裡
await之後,仍想用事發當下的值),只是逃生門便宜到不成 pattern:讀出來抄進 local 變數就是一份快照,普通的值,沒有「兩份互相同步」的維護成本。所以兩個模型是鏡像:React 預設快照,要最新值得走本篇整套;signal 預設最新值,要快照抄一行。代價在另一頭。render 內一致性 signal 框架其實有替代品——同步更新內靠依賴圖的 glitch-free 傳播,保證 derived 值不會讀到一半新一半舊的中間態(TC39 signals proposal 把這列為規格要求);真正變貴的是 React 快照模型天生免費的東西——可中斷、可丟棄重來的 concurrent rendering。Solid 也做得到 transition+Suspense(useTransition),但得在依賴圖上額外 fork 出雙份值來模擬快照隔離,而不是像 React 那樣整棵樹天生凍結在一致的快照上。兩種一致性模型的取捨,Solid 作者 Ryan Carniato 的〈The Cost of Consistency in UI Frameworks〉有第一手比較。