以前實作頁面會遇到上方有「上一個」「下一個」兩顆按鈕,方便使用者連續編輯一批檔案的屬性。
試想一下情境:
在 A 檔案的頁面按「下一個」,網址變成 B、標題變成 B 的檔名,但表單裡的欄位,還是 A 的資料。 他沒注意到,改了一個欄位就按下儲存——B 檔案的屬性,被 A 的資料整個蓋掉了。
我重現了一次,確實如此。頁面上其他東西都換成 B 了,那個用 React Hook Form 寫的表單,固執地停在 A。
花了一整個晚上,才搞懂為什麼。
先說,這個坑在 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 都重跑,怎麼會有什麼被「留下來」?
這是我那一整晚真正學到的東西。
元件函式確實每次 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 把這件事直接說出來,比在元件裡偵測變化、再一個一個重設要清楚得多。
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 要穩定,而且要真的代表「這是誰」。
這是認命重建期的最後一天。
回頭翻 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 把兩件事收進同一個概念裡:你給我身分,我就照身分決定要保留還是重建。