iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 21 篇

Day 21|切到下一個檔案,表單還是上一個

  • 分享至 

  • xImage
  •  

以前實作頁面會遇到上方有「上一個」「下一個」兩顆按鈕,方便使用者連續編輯一批檔案的屬性。

試想一下情境:

在 A 檔案的頁面按「下一個」,網址變成 B、標題變成 B 的檔名,但表單裡的欄位,還是 A 的資料。 他沒注意到,改了一個欄位就按下儲存——B 檔案的屬性,被 A 的資料整個蓋掉了。

我重現了一次,確實如此。頁面上其他東西都換成 B 了,那個用 React Hook Form 寫的表單,固執地停在 A。

花了一整個晚上,才搞懂為什麼。


一、Angular 其實也有一樣的坑

先說,這個坑在 Angular 裡我是踩過的。

Angular Router 在只有路由參數改變時(/files/1 變成 /files/2),預設會重複使用同一個元件實例。ngOnInit 不會再跑一次,在 ngOnInit 裡初始化的表單,自然也還是舊的資料。

當年的解法是訂閱參數的變化,再手動把表單重設:

ngOnInit() {
  this.route.paramMap.pipe(
    switchMap(params => this.fileService.getFile(params.get('id')!)),
  ).subscribe(file => this.form.reset(file));
}

所以理論上我應該要想到。但在 React 裡,我一開始根本沒意識到「元件被重複使用」這件事,因為我以為元件就是一個函式——每次 render 都重跑,怎麼會有什麼被「留下來」?


二、React 怎麼決定「這是不是同一個元件」

這是我那一整晚真正學到的東西。

元件函式確實每次 render 都重跑,但 state 不是存在函式裡的。useState、useRef、React Hook Form 的內部狀態,都是 React 替每個「元件實例」保管的(Day 11 提過一次)。

那 React 怎麼知道這次 render 的 <FileMetaEditor>,跟上次的是不是同一個實例?

它看兩件事:

  • 類型:是不是同一個元件(同一個函式)
  • 位置:是不是在元件樹的同一個位置

兩個都一樣,React 就認定「這是同一個」,保留它的 state,只把新的 props 傳進去。

回到那個 bug:

// routes/files.$id.tsx
export default function FileDetail({ loaderData }: Route.ComponentProps) {
  return <FileMetaEditor file={loaderData.file} />;
}

從 A 切到 B,同一個路由、同一個位置、同一個 FileMetaEditor。React 認定它是同一個元件,保留了它的 state。新的 file 雖然傳進去了,但 React Hook Form 的 defaultValues 只在第一次建立時讀一次,之後 props 再怎麼變,表單裡的值都不會跟著變。


三、key 不只是給清單用的

我原本以為 key 只有一個用途:,在 .map() 渲染清單時告訴 React 每一項是誰。

其實 key 的意思更根本:它就是元件的身分。

React 判斷「是不是同一個元件」,其實看的是三件事:類型、位置,還有 key。只要 key 變了,就算類型和位置都一樣,React 也會認定這是另一個元件——把舊的整個卸載,再建立一個全新的。

所以修法只有一行:

<FileMetaEditor key={loaderData.file.id} file={loaderData.file} />

從 A 切到 B,key 從 A 的 id 變成 B 的 id。React 把 A 的編輯器整個丟掉,重新建立一個 B 的編輯器。React Hook Form 重新初始化,defaultValues 讀到的是 B 的資料。

順帶一起被重置的,還有所有附在這個元件上的東西:useState、useRef、還沒送出的錯誤訊息、useEffect 的清除函式也會先跑一次。乾乾淨淨,就像第一次打開這一頁。

另外兩種解法

當然,也可以不換 key。

一種是學 Angular 的寫法,用 useEffect 在 id 改變時手動重設:

useEffect(() => {
  form.reset(file);
}, [file.id]);

能動,但有兩個缺點。第一,useEffect 是在畫面畫出來之後才跑,所以會有一瞬間顯示舊資料。第二,它只重設了表單,元件裡其他的 state 還是會被保留,很容易漏掉。

另一種是 React Hook Form 自己提供的 values 選項,傳入的值改變時,它會自動更新表單:

const form = useForm({ values: file });

這個也可以,而且很直覺。但它只處理表單本身。

我最後選了 key,理由是:「換了一個檔案」本來就是「換了一個編輯器」。 用 key 把這件事直接說出來,比在元件裡偵測變化、再一個一個重設要清楚得多。


四、回頭看那句「用 index 當 key」

Day 3 我只用一句話帶過:「用 index 當 key 是經典陷阱,只有清單順序永遠不變時才能用。」

當時我其實說不太出為什麼。今天懂了:key 是身分,而 index 不是一個穩定的身分。

用 Day 6 的 FileRow 來看。每一列有一個重新命名的輸入框,用的是 defaultValue,值存在 DOM 裡:

{files.map((file, index) => (
  <FileRow key={index} file={file} />
))}

使用者在第二列的輸入框打了幾個字,還沒送出,然後刪掉了第一列。

刪除前                       刪除後
key=0  報告.pdf              key=0  合約.pdf    ← 拿到原本 key=0 的狀態
key=1  合約.pdf  ✏️ 打到一半  key=1  發票.pdf    ← 拿到原本 key=1 的狀態:打到一半的字
key=2  發票.pdf

刪掉第一列之後,「合約」變成了 index 0,「發票」變成了 index 1。React 只看 key,所以它認為 key=1 那個元件還在,只是 props 換了——於是打到一半的字,跑到了「發票」那一列上。

換成 key={file.id},每一列的身分跟著檔案走,刪掉一列,其他列的狀態都留在原位。

這跟 Angular @for 裡的 track 是同一件事。差別只在 Day 3 說過的:Angular 強制你寫,React 只在 Console 給你一個警告。

一個反向的坑

既然換 key 會重建元件,那就要小心別不小心每次都換:

<FileRow key={Math.random()} file={file} />   // ❌

這會讓每一次 render 都把每一列整個丟掉重建。效能很差,而且輸入框只要一重建,焦點就會不見——使用者打一個字,游標就消失一次。

key 要穩定,而且要真的代表「這是誰」。


五、Week 3 結束了

這是認命重建期的最後一天。

回頭翻 README,這一週我寫下了七條規則:

天 規則 Angular 原本怎麼處理
Day 15 前端環境變數加 VITE_,不放秘密 environment.ts
Day 16 功能之間只能從入口 import NgModule 的 exports
Day 17 大型表單用 React Hook Form 加 Zod Reactive Forms
Day 18 每個路由都要有 ErrorBoundary ErrorHandler
Day 19 能分享的狀態放網址 沒有對應,我以前放在 service
Day 20 從畫面測,API 用 MSW 攔 TestBed 加 DI 替換
Day 21 清單用穩定 id 當 key,要重置就換 key track,而且是強制的

排在一起看,我才發現一件事:

這份 README,有一大半是 Angular 原本就內建、現在得自己寫下來的規格。

NgModule 幫我守住邊界、Reactive Forms 幫我管表單狀態、track 強迫我給清單身分、ErrorHandler 讓錯誤不會擴散。三年來我從來不用寫這些規則,因為框架已經是規則本身。

但也有幾條,是 Angular 從來沒逼我想過的:狀態該不該放在網址上?測試應該綁在實作上,還是綁在行為上?這些問題 Angular 不是答錯了,而是它的預設讓我不需要回答。

Week 1 我在找對應物,Week 2 我在抗拒,Week 3 我在立規矩。立著立著,才看清楚 Angular 替我做過多少決定。


今天的結論

key 不是清單專用的東西,它是 React 判斷「這是不是同一個元件」的身分證。

類型和位置一樣,React 就保留狀態;key 一換,React 就整個重建。這解釋了兩件看似無關的事:為什麼切換檔案時表單卡在舊資料,以及為什麼用 index 當 key 會讓輸入框的內容跑到別列。

Angular 用 track 強制你處理清單的身分,用 paramMap 讓你自己處理元件的重複使用。React 把兩件事收進同一個概念裡:你給我身分,我就照身分決定要保留還是重建。


上一篇
Day 20|測試不再問元件,改問畫面
下一篇
Day 22|撞牆與和解:在 React 遇見 Angular 幫我擋下的全域 CSS 災難
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 23 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言