維持 React 資料流可靠性的重要關鍵:immutable state
什麼是 mutate?
在程式設計中,mutate(改變) 指的是直接修改某個物件或陣列的內容,而不是創建一個新的物件或陣列來表示改變後的狀態。簡單來說,當你對一個變數的內容進行修改,像是改變物件的屬性值或陣列的元素,這個行為就是 mutate。
在 React 中,state 是用來管理元件的狀態,React 期望 state 是 不可變的(immutable),這意味著你不應該直接修改 state 的內容,而是應該創建一個新的 state 來反映改變。直接修改 state(mutate)會導致問題,例如 React 無法正確檢測到狀態變化,進而影響元件的重新渲染。
為什麼 mutate 是問題?
React 使用 虛擬 DOM(Virtual DOM) 來比較新舊狀態,決定是否需要更新畫面。如果直接 mutate state,React 可能無法察覺到變化,因為物件或陣列的參考(reference)並未改變。這會導致畫面更新不正確,甚至引發 bug。
Mutate 的範例
以下是一個 mutate state 的錯誤示範:
import React, { useState } from "react";
function Counter() {
const [user, setUser] = useState({ name: "小明", age: 20 });
const handleAgeChange = () => {
// 直接修改 state(mutate)
user.age = user.age + 1; // 錯誤!直接改變了 user 物件
setUser(user); // 即使呼叫 setUser,React 可能無法檢測到變化
};
return (
<div>
<p>姓名:{user.name}</p>
<p>年齡:{user.age}</p>
<button onClick={handleAgeChange}>增加年齡</button>
</div>
);
}
export default Counter;
問題說明:
-
在 handleAgeChange 中,我們直接修改了
user.age
,這是對 state 物件的 mutate。 -
即使呼叫了
setUser(user)
,因為 user 的參考並未改變,React 不會認為 state 有變化,因此畫面不會更新。 -
這可能導致年齡值實際上改變了,但畫面上顯示的年齡不會更新。
保持 state 的 immutable
什麼是 immutable state?
Immutable state 指的是在更新 state 時,永遠不直接修改原本的物件或陣列,而是創建一個新的物件或陣列來表示更新後的狀態。這樣可以確保 React 正確檢測到狀態變化,並觸發必要的重新渲染。
為什麼要保持 state 的 immutable?
-
確保 React 能檢測變化:React 依靠物件參考的比較來判斷 state 是否改變。如果 state 是 immutable 的,每次更新都會產生一個新的參考,React 就能正確檢測到變化。
-
提高程式碼可預測性:Immutable state 讓程式碼行為更可預測,避免因直接修改物件而引發的副作用(side effects)。
-
方便除錯:Immutable state 讓你更容易追蹤狀態的變化,因為每個狀態都是獨立的快照。
-
支援時間旅行(Time Travel Debugging):在像 Redux 這樣的狀態管理庫中,immutable state 是實現時間旅行除錯的基礎。
如何保持 state 的 immutable?
以下是幾個在 React 中保持 state immutable 的常用方法:
1. 使用展開運算子(Spread Operator)
展開運算子 (...) 可以用來複製物件或陣列,創建一個新的副本,然後再對副本進行修改。
範例:更新物件
import React, { useState } from "react";
function Counter() {
const [user, setUser] = useState({ name: "小明", age: 20 });
const handleAgeChange = () => {
// 使用展開運算子創建新物件
const newUser = { ...user, age: user.age + 1 };
setUser(newUser); // 更新 state
};
return (
<div>
<p>姓名:{user.name}</p>
<p>年齡:{user.age}</p>
<button onClick={handleAgeChange}>增加年齡</button>
</div>
);
}
export default Counter;
說明:
-
{ ...user, age: user.age + 1 }
創建了一個新的物件,複製了 user 的所有屬性,並更新 age。 -
因為傳遞給 setUser 的是新物件,React 會檢測到變化並重新渲染元件。
範例:更新陣列
import React, { useState } from "react";
function TodoList() {
const [todos, setTodos] = useState(["買牛奶", "寫作業"]);
const addTodo = () => {
// 使用展開運算子創建新陣列
const newTodos = [...todos, "運動"];
setTodos(newTodos); // 更新 state
};
return (
<div>
<ul>
{todos.map((todo, index) => (
<li key={index}>{todo}</li>
))}
</ul>
<button onClick={addTodo}>新增待辦事項</button>
</div>
);
}
export default TodoList;
說明:
-
[...todos, '運動']
創建了一個新陣列,包含原有的 todos 和新項目。 -
這確保了 todos 的 immutable 性,避免直接修改原陣列。
2. 使用 map 或 filter 處理陣列
當需要修改陣列中的某個元素或刪除元素時,可以使用 map 或 filter 來創建新陣列。
範例:更新陣列中的某個元素
import React, { useState } from "react";
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "買牛奶", done: false },
{ id: 2, text: "寫作業", done: false },
]);
const toggleTodo = (id) => {
// 使用 map 創建新陣列
const newTodos = todos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
);
setTodos(newTodos);
};
return (
<div>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.done ? "line-through" : "none" }}
>
{todo.text}
</span>
<button onClick={() => toggleTodo(todo.id)}>
{todo.done ? "取消完成" : "標記完成"}
</button>
</li>
))}
</ul>
</div>
);
}
export default TodoList;
說明:
-
map 方法遍歷 todos 陣列,當找到對應的 id 時,使用展開運算子創建一個新物件,更新 done 屬性。
-
其他未匹配的元素保持不變,這樣確保了陣列的 immutable 性。
範例:刪除陣列中的元素
import React, { useState } from "react";
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "買牛奶", done: false },
{ id: 2, text: "寫作業", done: false },
]);
const deleteTodo = (id) => {
// 使用 filter 創建新陣列
const newTodos = todos.filter((todo) => todo.id !== id);
setTodos(newTodos);
};
return (
<div>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.text}
<button on上Click={() => deleteTodo(todo.id)}>刪除</button>
</li>
))}
</ul>
</div>
);
}
export default TodoList;
說明:
-
filter 方法返回一個新陣列,僅包含不符合條件的元素(即 id 不等於指定值的元素)。
-
這樣不會修改原始陣列,保持了 immutable 性。
3. 使用物件的 assign 方法
如果你不喜歡使用展開運算子,也可以使用 Object.assign 來複製物件。
範例:
import React, { useState } from "react";
function Counter() {
const [user, setUser] = useState({ name: "小明", age: 20 });
const handleAgeChange = () => {
// 使用 Object.assign 創建新物件
const newUser = Object.assign({}, user, { age: user.age + 1 });
setUser(newUser);
};
return (
<div>
<p>姓名:{user.name}</p>
<p>年齡:{user.age}</p>
<button onClick={handleAgeChange}>增加年齡</button>
</div>
);
}
export default Counter;
說明:
-
Object.assign({}, user, { age: user.age + 1 })
創建一個新物件,複製 user 的屬性並更新 age。 -
這也是一種保持 immutable 的方式,但展開運算子通常更簡潔。
4. 使用 Immer(進階)
如果你的 state 結構很複雜,手動保持 immutable 可能會變得繁瑣。這時可以使用 Immer 函式庫,讓你用看起來像直接修改的方式來編寫程式碼,但實際上它會幫你生成 immutable 的結果。
安裝 Immer:
npm install immer
範例:使用 Immer 更新 state
import React, { useState } from "react";
import produce from "immer";
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "買牛奶", done: false },
{ id: 2, text: "寫作業", done: false },
]);
const toggleTodo = (id) => {
// 使用 produce 來更新 state
const newTodos = produce(todos, (draft) => {
const todo = draft.find((t) => t.id === id);
todo.done = !todo.done; // 直接修改 draft,Immer 會生成新陣列
});
setTodos(newTodos);
};
return (
<div>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.done ? "line-through" : "none" }}
>
{todo.text}
</span>
<button onClick={() => toggleTodo(todo.id)}>
{todo.done ? "取消完成" : "標記完成"}
</button>
</li>
))}
</ul>
</div>
);
}
export default TodoList;
說明:
-
produce 函數接受當前的 state 和一個 draft(草稿)函數,你可以在 draft 中直接修改物件或陣列。
-
Immer 會自動生成一個新的 immutable state,減少手動複製的麻煩。
總結
-
什麼是 mutate:直接修改物件或陣列的內容,會導致 React 無法正確檢測狀態變化,引發畫面更新問題。
-
保持 immutable 的方法:
-
使用展開運算子 (...) 複製物件或陣列。
-
使用 map 或 filter 處理陣列更新。
-
使用 Object.assign 複 Dz 製物件。
-
使用 Immer 簡化複雜 state 的更新。
-
-
好處:保持 state 的 immutable 性可以讓 React 正確檢