「useState(0) 我懂了。但我的表單有 10 個欄位,要寫 10 個 useState 嗎?還是用一個物件存? 我刪除陣列裡的一個項目,為什麼不能直接 todos.splice(index, 1)?」
這是每個 React 初學者都會卡住的地方。問題的根源只有一個: React 的「不可變更新」原則。 理解它,後面五個場景全部迎刃而解。
為什麼 React 要你「不可變更新」
比喻:React 就像一個視力不好的人——他不看你物件裡面的值, 只看「這個物件是不是換了一個新的」。你改了 user.age, 但還是同一個物件,他就說:沒變,不需要重新渲染。
直接修改(Mutate)
- • 修改原物件的屬性
- • 對陣列用 splice、push、pop
- • React 偵測不到變化
- • 畫面不更新,但資料已經壞掉
不可變更新(Immutable)
- • 建立新物件 {...old, key: val}
- • 陣列用 filter、map、展開
- • React 偵測到新參考
- • 畫面正確更新
場景 1布林開關(最簡單)
模態框開關、載入狀態、展開/收合——這類布林 state 到處都是。 寫法很簡單,但有一個值得注意的小細節:要用 functional update。
為什麼用 prev?
setIsOpen(prev => !prev) 讓 React 在執行 setter 的時候才去取最新的值, 而不是用閉包裡「快照」的 isOpen。在非同步操作、批次 setState 的情境下, 這個差異會讓你少踩很多坑。
場景 2物件狀態(表單最常見)
表單是物件 state 的最典型場景。多個欄位用一個物件管,更新時用展開運算子...prev保留其他欄位,只改目標欄位。
這樣寫的優點
• 一個 handleChange 搞定所有欄位,不用為每個欄位寫獨立的 handler
• 計算屬性名 [field] 讓函數通用化,傳入欄位名稱動態更新
• ...prev 展開確保不可變更新,其他欄位不受影響
場景 3陣列狀態(新增、刪除、修改)
待辦清單、購物車、留言列表——陣列 state 有三種常見操作。 記住一條規則:永遠回傳新陣列,絕對不要改原陣列。
| 操作 | 正確寫法 | 錯誤寫法(不要用) |
|---|---|---|
| 新增 | [...prev, newItem] | prev.push(newItem) |
| 刪除 | prev.filter(item => item.id !== id) | prev.splice(index, 1) |
| 修改 | prev.map(item => item.id === id ? {...item, ...changes} : item) | 直接修改 item 屬性 |
| 排序 | [...prev].sort(...) | prev.sort(...)(sort 會改原陣列!) |
場景 4衍生狀態——不要把可以算出來的值放進 state
這是很多人寫 React 時不自覺犯的錯:把一個可以從現有 state 計算出來的值, 又另外存成獨立的 state。這樣做不只多此一舉,還會製造同步問題。
衍生狀態的判斷標準
問自己:「這個值能不能從現有的 state 或 props 直接算出來?」 如果可以,就不要把它存成 state,直接在 render 時計算就好。 如果你需要快取或避免重複計算,用 useMemo——但這是進階話題,先把「不要多存 state」的直覺建立起來。
場景 5多個 useState vs 一個物件 state
這是很多人糾結的問題。答案其實很簡單,看這些值有沒有關聯就好。
選多個 state 的情況
- →這些值各自獨立更新
- →更新頻率差異很大(一個頻繁,一個很少動)
- →概念上沒有關聯
選物件 state 的情況
- →這些值概念上屬於同一個「實體」
- →通常一起顯示、一起提交
- →典型案例:表單欄位、API 回應資料
Functional Update:為什麼要用 prev
我們在前面幾個場景反覆用到 prev =>,現在解釋清楚為什麼。
Functional Update 的使用原則
只要新的 state 依賴於舊的 state(例如 toggle、count+1、陣列展開), 就用 prev => 的形式。 如果新的 state 和舊的 state 完全無關(例如 setName('Joseph')), 直接傳值即可。
速查卡:五大場景總結
| 場景 | 建議寫法 | 核心原則 |
|---|---|---|
| 布林開關 | setFlag(prev => !prev) | 用 prev 避免 stale closure |
| 物件更新單一欄位 | setState(prev => ({ ...prev, key: value })) | 展開保留其他欄位 |
| 陣列新增 | setState(prev => [...prev, newItem]) | 展開建立新陣列 |
| 陣列刪除 | setState(prev => prev.filter(...)) | filter 不改原陣列 |
| 陣列修改 | setState(prev => prev.map(...)) | map 不改原陣列 |
| 衍生值 | 直接算,不存 state | 能算的不要多存 |