同一個值同時存一份 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 內建的 API,慣用寫法就是本篇的主角 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 把最新的 state 寫進 ref,讓兩份不脫鉤。
關鍵細節:在哪裡寫入 ref
上一節說,兩份值靠「每次 render 把 state 寫進 ref」不脫鉤。那這行寫入該放在哪裡?這是這個 pattern 最容易被講錯的地方,常見寫法有幾種:
const textRef = useRef(text);// 寫法一:render 期間寫入textRef.current = text;// 寫法二三四:各種 effect 裡寫入useEffect(() => {textRef.current = text;});useLayoutEffect(() => {textRef.current = text;});useInsertionEffect(() => {textRef.current = text;});
先看官方的立場,不只一句 caveat:useRef 文件明白寫著「不要在 render 期間讀寫 ref.current(初始化除外),這會讓元件行為不可預測」;官方文件唯一示範過這個 pattern 的地方(legacy Hooks FAQ),一律把寫入放在 useEffect 裡,並附上「我們不推薦這個 pattern,僅為完整性列出」;新的 refs lint 規則更把 render 期間寫入幾乎逐字列為錯誤範例(唯一豁免是 lazy initialization),React Compiler 偵測到違規時會放棄最佳化該元件。所以官方立場一致:render 寫入違規,寫在 effect 裡才合規。
我的偏好和常見教學都不同:四種寫法的合理排序是 useInsertionEffect > render 寫入 ≥ useLayoutEffect > useEffect,我認為最好的選項是幾乎沒人用的 useInsertionEffect,其次是官方明文反對的 render 寫入,最常被教的兩種反而會被我排在後面。
以下是論證,先從官方理由的性質講起。文件從未舉出這種 idempotent 寫入(值完全由當次 render 輸入決定)在現行 React 下會出錯的實例;useRef 文件也承認,元件可能仍然能動,但 React 正在加的新功能會依賴這些預期。禁令的實質根據是為未來保留的純度契約,不是現行的 bug;生態系也確實分裂:react-use 和 ahooks 的 useLatest 就是在 render 期間寫、行之有年,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 之前同步完成 | 幾乎只剩其他 insertion effect |
表格裡的行為都可以用真實的 React 19 重現,可執行的實驗程式碼在 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 的,只有用到 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 點名過超前的風險,指出 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+;嚴格說它也是超出設計用途的用法(官方文件說這個 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。不過先把範圍講清楚:官方並沒有把整個「兩種時間語意」的需求做成 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,此時父元件寫入 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 暴露給父元件的方法,這些呼叫路徑都不是從 effect 出發,Effect Event 的規則不允許;需求是一個「identity 穩定、可以傳給子元件」的 callback,官方刻意不支援,而 legacy FAQ 示範這個 pattern 用的正是這種場景(useCallback 包的穩定 handleSubmit)。遇到這些,就回到上一節的結論,手動維護寫入時機。