key 強制 remount 切斷 stale state

Pattern

當一個 component(特別是含有內部 state / form / refs 的)會在不同的「context」之間被重用,而 context 切換時你需要它徹底忘掉之前的狀態,就把 context 的 id 放到 key 上:

<SomeComponent key={contextId} ... />

React 看到 key 變了就會 unmount 舊的、mount 新的,內部 state、useRefuseState、form instance 全部砍掉重練。

為什麼需要這個 pattern

React 預設「同位置 + 同 type」的 element 會被 reuse,內部 state 跟著保留。這個行為在大部分情境下是優點(不用重建 DOM、不用重抓資料),但有兩個情境會變陷阱:

  1. 同一個 component 服務多個 context(多 Project、多 Tenant、多 User):props 變了,但 component 內部的 form state / cached 計算結果 / refs 還停留在上一個 context。
  2. Race condition:context 已經切走、新的 data 還在 loading,使用者操作的是「正在 transition 的中間狀態」——UI 看起來對,底層 state 是舊的。

這類 bug 在 local 很難複現,因為要剛好撞到時序。

何時用、何時不用

  • Drawer / Modal / Dialog 裡裝 form,會跨 context 開關
  • Page 級的 component 綁定某個 entity(Project、Tenant、Org),切換時要全部重來
  • useRef 抓 DOM 或 timer,context 換了之後 ref 不能跨過去

不用

  • Component 內部沒有 state,純展示——沒必要 remount,浪費效能
  • State 應該明確跟著 props 同步——用 useEffect 或 derived state,不要靠 remount 偷懶
  • 高頻變動的 id(每次 render 都會變)——會變成每次都 remount,等於沒有 React reconciliation

範例:跨 Project 切斷 Drawer 內 form state

// 沒有 key:切 Project 後 Drawer 內 form 還抓著前一個 Project 的 product list
<AllocationDetailDrawer ... />
 
// 加上 key={project?.id}:切 Project 直接 remount,form state 重來
<AllocationDetailDrawer key={`detail-${project?.id ?? ''}`} ... />

這在 CarbonX 修過一個 production bug:Project A 的 productId 被夾帶到 Project B 的 submit payload,導致排放量算不出來。詳細案例見 Drawer 未隨 context unmount 導致 stale form state 送錯 id

跟相關 pattern 的關係

  • Drawer Form 資料不更新的解決方案:同一個原理的另一個應用——資料 refetch 後 Drawer 裡的 form 沒更新,解法是把 form 抽到 Drawer 外、用 Drawer 開關控制 mount/unmount。
  • 共通原則:當 React 預設的 reconciliation 行為「保留 state」變成負擔時,主動用 mount/unmount 切斷生命週期,比想辦法手動 sync state 簡單又可靠。

Trade-offs

  • 效能成本:remount 會重新跑 mount 時的 effects、重新抓 data。如果 component 很重(複雜的子樹、大量 query),代價不小。
  • API call 重複:mount 時觸發的 useEffect 會重跑,可能多打一次 API。通常可以接受,但要意識到這件事。
  • 替代方案:如果 component 不重、state 不多,用 useEffect 監聽 contextId 然後手動 reset 也行。但邏輯更容易漏,特別是有多個 state / ref 要 reset 的時候。key 是「核彈」,但簡單可靠。