深入理解 useEffect
前情提要
剛開始學 React 的時候,我對 useEffect 的理解就是「用來處理副作用的 Hook」,然後就開始到處亂用 XD。後來在實際專案中踩了不少坑,像是無限迴圈、Race Condition、不必要的 re-render 等等,才發現自己對 useEffect 的理解太淺了。後來讀了 Dan Abramov 的 A Complete Guide to useEffect 這篇經典文章,才終於建立起正確的心智模型。這篇文章整理了我對 useEffect 的理解,希望能幫助路過的讀者少走一些彎路。
useEffect 的核心概念
useEffect 是 React 中用於處理 副作用 (Side Effects) 的 Hook。副作用指的是任何與外部系統互動的操作,例如:網路請求、DOM 操作、訂閱事件、計時器等。
基本語法
useEffect(setup, dependencies?)
setup: Effect 函數,可選擇性回傳一個 cleanup 函數dependencies: 依賴陣列,決定 Effect 何時重新執行
依賴陣列的三種情況
| 依賴陣列 | 執行時機 |
|---|---|
| 不傳入 | 每次 render 後都執行 |
[] 空陣列 | 僅在 mount 時執行一次 |
[a, b] | 初始 render 及 a 或 b 變化時執行 |
依賴陣列中的比較是使用 Object.is 來判斷的,這意味著如果依賴項是 object 或 function,每次 render 都會被視為「新的」,很容易造成非預期的重新執行。
同步化思維:Effect 的正確心智模型
"It's all about the destination, not the journey." — Dan Abramov
許多開發者習慣用 Class Component 的生命週期(componentDidMount、componentDidUpdate、componentWillUnmount)來理解 useEffect,但這會導致錯誤的心智模型。
「響應生命週期」vs「同步化」的差異
先來看看這兩種思維的差別:
生命週期思維(錯誤 ):
- 「component mount 時,我要做 X」
- 「props 改變時,我要做 Y」
- 「component unmount 時,我要做 Z」
- 關注的是:「什麼時候」發生了什麼事件
同步化思維(正確):
- 「我要讓外部系統 A 的狀態,永遠與 React 的 state/props 保持一致」
- 關注的是:「最終狀態」應該是什麼
舉個具體的例子:假設你要根據 userId 訂閱聊天室。
// ❌ 生命週期思維:「mount 時訂閱,userId 變化時重新訂閱,unmount 時取消」
// 這種思維會讓你想:我要處理三種「事件」
// ✅ 同步化思維:「聊天室訂閱狀態要與 userId 同步」
// 這種思維 會讓你想:不管 userId 怎麼變,訂閱狀態最終要正確
useEffect(() => {
const connection = ChatAPI.connect(userId);
return () => connection.disconnect();
}, [userId]);
兩種寫法的程式碼可能一樣,但思考方式不同。同步化思維讓你專注於「結果」,而不是「過程中發生了什麼」。
區別的重要性
因為當你用生命週期思維時,很容易寫出這種程式碼:
// ❌ 生命週期思維:「mount 時 fetch 一次資料」
useEffect(() => {
fetchData();
}, []); // 故意寫空陣列,因為「只想在 mount 時執行」
但如果 fetchData 依賴某個 prop,這就會出 bug。正確的思維應該是:
// ✅ 同步 化思維:「資料要與 query 保持同步」
useEffect(() => {
fetchData(query);
}, [query]); // query 變了,資料就要重新同步
簡單來說:不要想「什麼時候執行」,要想「要同步什麼」。
useEffect 不是生命週期
useEffect 的本質是 同步化 (Synchronization),而非響應生命週期事件。它的職責是讓 React 外部的系統與當前的 props 和 state 保持同步。
function Greeting({ name }) {
useEffect(() => {
// 將 document.title 與 name 同步
document.title = `Hello, ${name}`;
});
return <h1>Hello, {name}</h1>;
}
無論 name 從 "Dan" 變成 "Yuzhi",還是直接渲染 "Yuzhi",最終結果都應該相同。這就是「同步化」的概念:我們關心的是最終狀態,而非變化的過程。
每次 Render 都有自己的 Effect
這是理解 useEffect 最關鍵的概念:每次 render 都會產生一個全新的 Effect 函數,它會「捕獲」該次 render 的 props 和 state。
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
// 這個 Effect 捕獲的是「這次 render」的 count 值
setTimeout(() => {
console.log(`Count: ${count}`);
}, 3000);
});
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}
如果快速點擊 5 次,3 秒後會依序印出 0, 1, 2, 3, 4,而非 5 個 5。這是因為每個 Effect 都捕獲了它所屬 render 的 count 值。
Cleanup 函數的運作機制
Cleanup 函數用於「撤銷」Effect 的操作,例如取消訂閱、清除計時器等。
執行時機
React 會在以下時機執行 cleanup 函數:
- 下一次 Effect 執行之前(依賴項變化時)
- Component unmount 時
重要的是,cleanup 函數同樣會捕獲它所屬 render 的 props 和 state:
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
return () => {
// 這裡的 props.id 是「定義這個 cleanup 時」的值
ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
};
});
執行順序
當 props.id 從 10 變成 20 時,執行順序如下:
- React 渲染
{id: 20}的 UI - 瀏覽器繪製畫面
- React 執行
{id: 10}的 cleanup(取消訂閱 id=10) - React 執行
{id: 20}的 Effect(訂閱 id=20)
這個順序確保了 cleanup 總是能正確清理「舊的」訂閱。
依賴陣列的正確使用
不要對 React 說謊
這是 Dan Abramov 在文章中反覆強調的重點:依賴陣列必須包含 Effect 中使用的所有 reactive values(props、state、以及在 component 內部宣告的變數和函數)。
很多人(包括以前的我)會為了「讓 Effect 只執行一次」而故意寫 [],但這樣做只是在欺騙 React,最終會導致難以追蹤的 bug。正確的做法是重構程式碼,讓 Effect 真的不需要那些依賴。
// ❌ 錯誤:遺漏依賴項
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // 使用了 count,但沒有加入依賴
}, 1000);
return () => clearInterval(id);
}, []); // 空依賴會導致 count 永遠是初始值
// ✅ 正確:使用 functional update 移除依賴
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // 不需要讀取 count
}, 1000);
return () => clearInterval(id);
}, []);