Skip to main content

維持 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?

  1. 確保 React 能檢測變化:React 依靠物件參考的比較來判斷 state 是否改變。如果 state 是 immutable 的,每次更新都會產生一個新的參考,React 就能正確檢測到變化。

  2. 提高程式碼可預測性:Immutable state 讓程式碼行為更可預測,避免因直接修改物件而引發的副作用(side effects)。

  3. 方便除錯:Immutable state 讓你更容易追蹤狀態的變化,因為每個狀態都是獨立的快照。

  4. 支援時間旅行(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 的方法

    1. 使用展開運算子 (...) 複製物件或陣列。

    2. 使用 map 或 filter 處理陣列更新。

    3. 使用 Object.assign 複 Dz 製物件。

    4. 使用 Immer 簡化複雜 state 的更新。

  • 好處:保持 state 的 immutable 性可以讓 React 正確檢