useEffect 入門:畫面變動後要做的事怎麼寫?
更新日期: 2025 年 4 月 17 日
當你學會了 useState
,可以讓元件「有自己的資料」後,下一步會發現:
「有些事情我不是在 render 當下要做,而是畫面顯示完後才做。」
舉幾個例子:
- 向後端 API 發送請求載入資料
- 開始一個計時器
- DOM 操作(例如設定 focus)
- 設定訂閱事件、WebSocket、甚至是 Google Analytics 追蹤
這些「畫面變動後」的任務,我們統稱為 副作用(side effect)。
在 React 中,我們用 useEffect
來做這些事!
什麼是副作用(side effect)?
很多人一開始聽到「副作用(side effect)」會以為是 壞事,就像吃藥會想睡、開刀留下疤那種副作用。
但在程式設計裡的「副作用」,其實跟吃藥的副作用不太一樣。雖然用了同樣的詞,但它的重點不是「好壞」,而是指:
⚠️「它做了你本來不期望的額外事情,不只是單純的計算。」
而這些額外的行為,會讓這段程式變得不那麼純粹、也比較難掌控。
👨🏫 幫你拆解一下「副作用」這個詞的真正意涵:
- 「副」:不是主要目的,但還是會發生的事
- 「作用」:對某些東西造成了影響
所以說「副作用」的意思,其實只是:
💡「你原本只是想算出一個結果,但這段程式還做了其他事,而且會影響其他東西。」
✅ 一個例子就能懂(真的不難)
function add(a, b) {
return a + b;
}
✅ 純函式 → 沒有副作用
只回傳結果,不會做其他事。
但如果你這樣寫:
function addAndLog(a, b) {
console.log(a + b);
return a + b;
}
👀 看似只是多印一下,但這就有副作用了!
因為它不只是計算結果,還跑去影響了外部世界(在 console 印出東西)。
這種「額外的行為」就是所謂的副作用,不代表不好,但你就得特別小心去管理它。

在 JavaScript 中,副作用長什麼樣?
副作用的重點不是「這段程式有多複雜」,而是:
它做了不只 return 結果這件事,它還影響了外部世界。
行為 | 是否副作用 | 為什麼? |
---|---|---|
const total = a + b | ❌ 不是 | 純粹根據輸入輸出,沒有影響別的東西 |
console.log("Hello") | ✅ 是 | 把訊息印出到外部(瀏覽器的 console) |
fetch(...) | ✅ 是 | 對伺服器發出請求 |
document.querySelector(...) | ✅ 是 | 主動操作 DOM,干擾 React 的虛擬 DOM 控制 |
setTimeout(...) | ✅ 是 | 開啟計時器,讓未來某個時間點自動執行某事 |
localStorage.setItem(...) | ✅ 是 | 改變瀏覽器儲存空間的資料 |
為什麼 React 這麼在意副作用?
🔄 用生命週期的角度來看:React 哪些階段能做副作用?
React 元件其實經歷了幾個階段(以 class component 為例說明,function component 背後邏輯一致):
階段 | 方法 | 適合副作用嗎?為什麼? |
---|---|---|
初始化(Mount) | constructor() | ❌ 不適合:這是設定初始值用的,不要做任何 I/O 或邏輯行為 |
初始化(Mount) | render() | ❌ 完全禁止副作用:這是「算畫面用」的,應該是純函式 |
初始化(Mount) | componentDidMount() | ✅ 非常適合:畫面已完成,可以開始做 API 請求、DOM 操作、設定事件 |
更新(Update) | render() | ❌ 一樣禁止:更新時的 render 一樣是純函式,不能做副作用 |
更新(Update) | componentDidUpdate() | ✅ 適合:畫面更新完畢後,可以根據狀態差異做副作用(但要記得判斷前後變化) |
卸載(Unmount) | componentWillUnmount() | ✅ 適合:適合做資源清理,例如移除事件監聽、取消計時器等 |
✅ 所以副作用可以做,但要「放對位置」
副作用不是壞事,它是現代應用不可避免的必需品(你總要打 API、加事件、操作 DOM 吧),但:
❗ 不是每個階段都能安全地做副作用,特別是 render 這個階段最危險!
那為什麼這麼在意 render 階段呢?我們來一步步拆解原因:
💡 什麼是「render 階段」?
在 React 的元件生命週期中,render 階段發生在:
- 組件第一次掛載(mount)時
- 組件的 state 或 props 改變,導致重新渲染(update)時
此時 React 正在「計算畫面要長怎樣」,會建立一個虛擬 DOM(Virtual DOM),還沒真正動到畫面!
👉 所以 render 階段應該是純粹的計算過程:
只根據 props、state 得出 JSX,React 才能根據這個結果決定要怎麼更新畫面。
⚙️ React 為什麼會這麼設計?因為它的「渲染機制」很特殊!
React 的渲染過程是這樣的:
- 它會試著批次處理更新
- 它有時會預先算好畫面再決定要不要更新(如 Concurrent Mode)
- 它甚至可能在中途中斷、取消、再重新 render
這代表什麼?代表 render 不一定保證會執行到最後。
👉 所以如果你在 render 裡發出 API 或直接操作 DOM,
React 可能只是「試算了一下」,這段副作用卻已經被你偷跑出去執行了!
結果就會造成畫面混亂或效能問題。
🚫 如果你在 render 階段做副作用,會發生什麼事?
這時候副作用就像是不該在計算過程中偷跑的「突發事件」,會讓整個流程不穩定:
情況 | 可能造成的問題 |
---|---|
在 render 裡發送 API 請求 | 每次重新 render 都會送一次,造成重複請求 |
在 render 裡印出 log | log 數量爆炸、除錯混亂 |
在 render 裡直接操作 DOM | React 可能還沒動到 DOM,你的操作會無效或衝突 |
設定計時器、事件監聽 | 如果沒管理好,會註冊多次,導致記憶體洩漏 |
這就像你在報稅時,還沒寫完報表就突然衝出去寄信、繳錢、打電話給客戶,流程全亂了。
🤯 那以前真的有人在 render 裡亂寫副作用嗎?
是的,React 早期(還有 class component 時代)真的很多人這樣寫!
當時的 class component 裡面有個 render()
方法,大家一開始搞不清楚差異,就會把各種行為都塞進去,比如:
render() {
fetch('/api') // ❌ 錯!這會在每次 render 都送出請求
return <div>Hello</div>;
}
這就導致很多問題:
- 重複請求太多,API 爆量
- 畫面更新一次,結果印十次 log
- 自己動 DOM 跟 React 搶工作,結果畫面閃爍
- 記憶體用爆了,因為事件監聽一直沒清掉
為了解決這些問題,React 推出 Hook 系統 + 嚴格 render 原則
✅ 那副作用到底該寫在哪裡?
既然我們知道 render 階段不能做副作用,那麼——
🔍 什麼時候、用什麼方式,才是寫副作用的正確位置?
這就取決於你用的是哪一種元件寫法:class component 還是 function component。
🧱 如果你用的是 class component(類別元件)
你可以用以下這些生命週期方法來安全地處理副作用:
componentDidMount()
:元件掛載完成後執行一次,適合做 API 請求、DOM 操作componentDidUpdate()
:元件更新後執行一次,適合根據 props 或 state 的變化做事(記得判斷前後差異)componentWillUnmount()
:元件即將卸載時呼叫,適合做資源清理(像是清除計時器或事件監聽)
這些都是 React 官方推薦且歷久不衰的做法,適用於 class component 架構中。
🪝 如果你用的是 function component(函式元件)
函式元件沒有這些生命周期方法,所以 React 提供了 useEffect
這個 Hook,讓你:
- 在元件掛載後做副作用
- 在特定 state 或 props 改變時重新執行副作用
- 在元件卸載時清除資源
這是函式元件唯一正規且安全的方式來處理副作用,使用範例如下:
useEffect(() => {
// 執行副作用
return () => {
// 清除副作用
};
}, []);
👉 useEffect
的具體寫法與用法,我會在下一段落詳細介紹。
基本語法:useEffect 怎麼寫?
在 React 的函式元件中,當你想執行「畫面變動後的動作」(也就是副作用),你會用到 useEffect
這個 Hook。
基本語法:
import { useEffect } from 'react';
useEffect(() => {
// ✅ 在這裡寫副作用邏輯(像是 API 請求、DOM 操作、事件註冊...)
});
這段程式碼的意思是:
每次元件「畫面渲染完畢」後(包含初次渲染與更新),就會執行一次裡面的副作用程式碼。
useEffect 的執行時機
寫法 | 執行時機 | 對應 class 寫法 |
---|---|---|
useEffect(fn) | 每次 render 後都會執行 | componentDidMount() + componentDidUpdate() |
useEffect(fn, []) | 只在第一次掛載後執行一次 | componentDidMount() |
useEffect(fn, [count]) | 當 count 改變時才執行 | componentDidUpdate()(需手動比對變化) |
實際範例:依賴陣列控制副作用的「執行時機」
🎯 1. 每次畫面更新後都執行(少見)
useEffect(() => {
console.log('畫面更新了!');
});
- 沒有加第二個參數(依賴陣列)
- 不管是 props 還是 state 改變,只要觸發 render,就會執行
- 較少使用,容易造成副作用執行過頭
🧭 2. 只執行一次(掛載時)
useEffect(() => {
console.log('畫面第一次出現時執行!');
}, []);
- 空陣列
[]
表示這段程式「沒有依賴任何資料」 - 只會在元件第一次被加到畫面上時執行一次
- 最常用於初始化邏輯(像是 API 載入、DOM 操作)
🔄 3. 某個資料改變時才執行
useEffect(() => {
console.log('count 改變了!');
}, [count]);
- 只有當
count
這個變數的值改變時才會重新執行 - 非常適合用來追蹤單一狀態變化後的行為(例如重新載入資料、更新畫面)
依賴陣列的核心概念:用好、用對,useEffect
才不會暴走!
在 useEffect
中,副作用什麼時候會執行,不是由你 setState()
這樣的程式碼直接控制,而是由「依賴陣列」來決定的。
這個陣列藏在 useEffect
的第二個參數裡:
useEffect(() => {
// 要執行的副作用
}, [依賴1, 依賴2]);
你可以把它想成是「一份觀察清單」
這個陣列的行為就像一個「監視器」,會持續追蹤你指定的變數,只要清單裡的某個變數有變化,useEffect
就會重新執行一次。
📌 規則是這樣:
狀況 | 結果 |
---|---|
清單裡的變數「有改變」 | 🔁 執行副作用 |
清單裡的變數「沒變」 | 💤 不執行(跳過) |
清單是空的 [] | ✅ 只在第一次掛載時執行一次 |
沒有寫依賴陣列 | 🔁 每次 render 都會執行(通常不建議這樣用) |
🧪 實際範例:
useEffect(() => {
console.log('count 改變了!');
}, [count]);
這段程式的意思是:
「當 count 改變時,我才要執行裡面的副作用。」
如果你點了按鈕,導致 count 從 0 ➝ 1,這段 effect 就會執行一次。
但如果你點了別的按鈕,影響的是其他 state(像是 name),那這段 effect 就完全不會動。
小心!依賴陣列寫錯會出現「超難查」的 Bug!
有兩種情況最常出事:
❌ 1. 忘記把某個會用到的變數放進去
useEffect(() => {
console.log(name); // 用到了 name,但沒放進依賴陣列
}, []);
這會導致:
- name 改變時,這段 effect 完全不會重新執行
- 你在 console 印出來的值,是「第一次那個 name」,之後怎麼改都不會變!
這種錯誤叫做「閉包陷阱(stale closure)」,尤其在非同步操作或監聽事件中超級容易中招。
❌ 2. 放進「會一直變動的東西」
useEffect(() => {
console.log('我又執行了!');
}, [Math.random()]);
這會導致:
- 每次 render,Math.random() 產生的新值都不同
- effect 每次都會重新執行(等於沒寫依賴陣列,反而更危險)
✅ 寫對依賴陣列的小技巧
- 你只要寫到的變數,就應該放進去依賴陣列!
- 除非你確定它不會改變(例如固定常數)
- 搭配 linter 工具(如 ESLint +
react-hooks/exhaustive-deps
)會自動幫你偵測漏放的依賴,千萬別關掉!
🧠 記住這個關鍵觀念:
❗ 依賴陣列不是你想讓它執行什麼時候的「觸發器」,
✅ 它是「這段副作用依賴哪些資料,那些資料變了,我才要重新執行」。
這樣才能保證資料一致性、避免效能浪費、還能防止難 trace 的 bug!
清理副作用(Cleanup):避免殘留資源
某些副作用不會自動結束,例如:
setInterval()
計時器會一直跑window.addEventListener()
設定的事件會一直聽- WebSocket 開啟後不關閉,會持續佔用資源
如果你沒有「在適當時機清除」這些副作用,會導致:
- 記憶體不斷增加(Memory Leak)
- 執行效能下降
- 畫面更新錯亂(例如重複綁定事件)
解法:在 useEffect
裡回傳一個清理函式
這個清理函式會在:
- 元件卸載(unmount)時執行
- 下一次重新執行副作用「之前」先執行
useEffect(() => {
const timer = setInterval(() => {
console.log('倒數中...');
}, 1000);
return () => {
clearInterval(timer); // ✅ 在元件卸載或副作用更新前先清除
};
}, []);
💡 這就像:「下一次動手做之前,先把上次留下來的東西收乾淨!」
常見需要清理的副作用範例:
類型 | 清除方式 |
---|---|
計時器 / 間隔器(setInterval, setTimeout) | clearInterval() / clearTimeout() |
自訂事件監聽(addEventListener) | removeEventListener() |
WebSocket / 資料串流 | close() |
訂閱類庫(如 Firebase、RxJS) | 呼叫 .unsubscribe() 或傳入 callback 清除 |
實作練習:用 useEffect 做一個「倒數計時器」
這是一個非常經典的練習,可以幫助你觀察 useEffect
如何根據依賴陣列控制執行時機,並學會如何搭配清除副作用(cleanup)。
實作程式碼
import { useState, useEffect } from 'react';
function CountdownTimer() {
const [count, setCount] = useState(10); // 初始倒數秒數為 10
useEffect(() => {
if (count === 0) return; // 倒數結束,不再執行
const timer = setTimeout(() => {
setCount((prev) => prev - 1); // 每秒減 1
}, 1000);
// 清除上一次的 timer,避免堆疊
return () => {
clearTimeout(timer);
};
}, [count]); // 當 count 改變時,才重新啟動副作用
return <div>倒數:{count} 秒</div>;
}
重點解析
行為 | 解釋 |
---|---|
setTimeout() | 每次 count 改變時,都安排下一秒執行 setCount |
return () => clearTimeout(...) | 在下一次 effect 執行「之前」,先清除舊的 timer,避免同時跑多個 |
if (count === 0) return; | 當倒數為 0,不再啟動新的 timeout(結束計時) |
setCount((prev) => prev - 1) | 使用 updater function,確保取到「最新的 count 值」,避免閉包陷阱 |
延伸提醒:為什麼不能只用 count - 1
?
如果你改成這樣 👇
setCount(count - 1); // ❌
你可能會遇到「值錯亂」或「跳秒」的 bug,因為閉包的 count
可能不是最新的。
用 setCount(prev => prev - 1)
才能確保你拿到的是最新的 state。
⚠️詳解版:為什麼不能只用 count – 1?
當你寫倒數計時器時,很多人會直覺地這樣寫:
setCount(count - 1); // ❌ 不建議這樣寫
看起來沒問題,但實際運作中,你可能會發現倒數不正常:
- 有時會跳兩秒
- 有時秒數沒變
- 有時會慢慢累積誤差
這些都不是「React 壞掉」,而是你中了 JavaScript 的閉包陷阱(stale closure)!
💥 原因:閉包裡的 count
不是最新的!
useEffect(() => {
const timer = setTimeout(() => {
setCount(count - 1); // ❌ count 是舊的值
}, 1000);
}, [count]);
雖然你每次 count
變化都有重新 render 和 useEffect,但:
❗
setTimeout
是非同步的,它捕捉到的count
值,其實是當下那一次 render 的值,而不是「下一秒最新的 count」。
所以你會發現:
時間點 | 你以為的 count | 其實捕捉到的 count |
---|---|---|
第一次倒數 | 10 | 10 |
第二次倒數 | 9 | 10 ← 錯了! |
第三次倒數 | 8 | 9 ← 又錯了 |
這就是閉包造成的「過時資料」問題,會導致邏輯錯亂、跳秒或失敗更新。
✅ 正解:使用 setCount(prev => prev - 1)
setCount((prevCount) => prevCount - 1); // ✅ 最安全做法
這種寫法稱為 updater function,它不依賴你當下 useEffect 捕捉到的 count
,而是:
讓 React 自動把「最新的 count 值」傳進來給你用!
這樣即使 setTimeout
在 1 秒後才觸發,也能保證拿到的是「最新狀態」,不會跳秒、不會失誤。
🧠 什麼時候該用 updater function?
只要你在非同步操作或連續更新的情境下,建議通通改用這種寫法:
舉例:
setX((prevX) => ...);
錯誤寫法 | 正確寫法 | 原因說明 |
---|---|---|
setCount(count + 1) | setCount(prev => prev + 1) | 防止閉包,拿到正確資料 |
setPage(page + 1) | setPage(prev => prev + 1) | 多人同時操作時避免 race |
setItems([...items, newItem]) | setItems(prev => [...prev, newItem]) | 保證拿到最新陣列 |
🎣 為什麼叫做「閉包陷阱」?它真的跟閉包有關嗎?
先回顧:什麼是閉包(closure)?
簡單來說:
閉包就是一個「函式記住了當下作用域裡的變數」,即使外部的作用域已經不存在,它也能繼續使用那些值。
舉個簡單例子:
function outer() {
let count = 0;
return function inner() {
console.log(count); // inner 記住了 count 的值
};
}
const fn = outer();
fn(); // 印出 0
這就是閉包:inner()
函式記住了 count = 0
的那個環境。
🚨 問題出在這裡:你以為拿到的是「現在的 count」,其實是「當初記住的 count」
在 useEffect
裡,當你寫這樣的程式碼:
useEffect(() => {
setTimeout(() => {
setCount(count - 1); // ❌ 問題發生在這裡
}, 1000);
}, [count]);
你以為這段 count - 1
會拿到最新的 count,但其實:
setTimeout()
是 1 秒後才執行的非同步函式- 這個函式當初在
useEffect
定義的時候,已經記住了當時的 count 值 - 即使過了 1 秒、畫面重新 render,裡面的函式還是使用舊的值
這個「函式記住舊變數」的現象,就是典型的閉包行為。
🧱 所以我們稱它為「閉包陷阱」
因為你在 React 的非同步副作用中:
❗「以為拿到的是最新狀態,實際上卻是閉包鎖住的舊資料」
而導致錯誤邏輯。
這在 React 中非常常見,尤其在:
setTimeout
setInterval
addEventListener
- 非同步 API 回呼(例如
.then()
)
裡面出現得特別多。
✅ 解法:讓 React 幫你拿最新的值 → 用 updater function
這就是為什麼 setCount((prev) => prev - 1)
是最安全的寫法。
因為這樣寫會讓你跳過閉包:
setCount((prev) => prev - 1); // ✅ React 會主動傳入「最新的 count」
你不再依賴「你自己記住的 count」,而是交由 React 傳入正確的值,這樣就不會踩中閉包陷阱。
實作練習:API 載入與畫面顯示
這是一個模擬「畫面初次掛載時呼叫 API 並載入資料」的範例,會幫你理解 useEffect
+ 空依賴陣列 []
的用法。
實作程式碼
import { useState, useEffect } from 'react';
function PostLoader() {
const [post, setPost] = useState(null); // 用來儲存載入的文章內容
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/posts/1')
.then((res) => res.json())
.then((data) => setPost(data)); // 載入成功後設定 state
}, []); // ✅ 空依賴陣列:只執行一次
return (
<div>
{post ? (
<div>
<h2>{post.title}</h2>
<p>{post.body}</p>
</div>
) : (
<p>載入中...</p>
)}
</div>
);
}
補充說明
fetch()
是一種常見的副作用(因為會向外部伺服器發送請求)- 使用空依賴陣列
[]
,可以讓這段副作用只在元件第一次掛載時執行 - 載入成功後使用
setPost
觸發重新 render,把資料顯示出來
常見陷阱與注意事項
問題情境 | 錯誤原因 | 解法或注意事項 |
---|---|---|
useEffect 無限執行 | 忘記加依賴陣列,或陣列內容每次 render 都會變 | 加上正確的依賴陣列 |
API 每次 render 都重新呼叫 | 寫了 useEffect(() => {...}) 卻沒加 [] | 加上空陣列 [],只執行一次 |
使用「過時的值」進行邏輯判斷 | 沒使用 updater function,或依賴陣列漏寫 | 使用 prev => 形式,或改用 useRef |
計時器 / 監聽器殘留 | 沒有在 useEffect 裡清除資源 | 使用 return () => {...} 進行 cleanup |
多次訂閱 WebSocket、動畫或事件 | 副作用重複執行沒清除 | 每次執行新副作用前,要記得清理上次的 |
總結
useEffect
是專門處理「副作用」的 Hook- 它會在畫面 render 完成後執行
- 可以控制副作用的執行時機(依賴陣列)
- 清理函式可以幫助你移除副作用造成的資源佔用
延伸閱讀建議
接下來你可以進一步學習:
useRef
:如何在畫面不 re-render 的情況下保存資料useContext
:跨元件傳遞資料useReducer
:處理複雜 state 的好幫手