關於useEffect
紀錄react中useEffect,並使用新的devtools觀察
useEffect用來允許我們將component與外部系統同步。
我們常會碰到,要讓一個component與外部系統去同步。像是讓React state去控制一些非React component,又或者是去連接server並顯示等等。Effect允許我們在render結束後執行一些方法,來將component與外部系統同步。也就是會有Effect。
1. 什麼是Effect呢?
如果我們的component是純函數的話,傳回props,就永遠是相同的結果。但裡面有effect的話,每次渲染完後,都會額外執行一些邏輯,所以不會每一次都一樣,這種就叫做Effect(副作用)。
這裡可以看一下一些對於Pure Function和非純函數的說明。
- 只透過參數接收輸入,只透過回傳值輸出。與外界交換資料的渠道是顯式的。
- 函數執行時,回傳的輸出不受外部影響 函數副作用side effect :
- 也是非純函數(Impure Function) —— 透過參數/回傳值以外的方式與外界交換資料,是隱式的。例如:讀寫全域變數、I/O 操作(讀取檔案、列印輸出等)。
- 函數執行時,除了回傳值外,還對外部產生影響。
ex.與外界交互
let num = 10;
function add(x: int, y: int) {
num = x + y
return num;
}2. useEffect的用途
在 React 中,當 Component Function 執行(Render 階段)時,我們不應該進行任何會與外部系統互動的操作,這些操作稱為副作用 (Side Effect),例如:向 API 請求資料或是手動修改 DOM。
為什麼要避免在 Render 時處理副作用呢?因為這會破壞元件的純粹性,可能導致:
- 結果不可預測: 由於外部資料的變動性,Component 行為會變得不穩定且難以追蹤。
- 效能阻塞: 副作用的處理會直接拖慢畫面的繪製與更新,導致使用者體驗到卡頓或延遲。
- 無限循環: 副作用若不小心觸發 Component 重新 Render,可能造成 Effect 不停重複執行。
為了解決這些問題,React 提供了 useEffect Hook,它是一個專門處理副作用的「隔離區」。
useEffect 的核心機制是:
- 延遲執行: 它會將 Effect 的處理延遲到 Component 畫面完成繪製 (Paint) 之後才執行,從根本上避免阻塞 UI 更新。
- 清理影響:
useEffect提供了「清理函式 (Cleanup Function)」的概念。當 Component 準備卸載或是 Effect 準備重新執行時,它會先呼叫清理函式,將前一次 Effect 留下的影響(例如事件監聽器、訂閱)安全地消除或抵消,避免不可預期的結果無限疊加,並防止記憶體洩漏 (Memory Leak)。
簡單來說,useEffect將Effect的處理隔離到每次render完成後才去執行,避免再Effect處理直接Block畫面的產生和更新。(也就是限制Effect執行是在render完後才執行,避免執行順序的不可預測性和Blocking問題)
3. 用法
useEffect(setup, dependencies?) 參數
-
setup : 處理Effect的函數,setup會選擇性返回一個cleanup函數,當組件被掛載到DOM的時候,React就會開始跑setup,如果當dependencies變換重新渲染,React就會先運行cleanup(如果有),然後重新使用New Value重新跑setup。如果從DOM上移除,那麼就會在執行最後一次的cleanup。
-
dependencies : setup中引用的所有響應式值的array。包含props、state以及所有直接在component內部聲明的variable和function。React會使用
Object.is來比較每個依賴像和它先前的value。
我們可以看一下下面使用的範例。 ex,
import { useEffect, useState } from "react";
async function fetchScore() {
const score = await new Promise<number>((resolve) => {
setTimeout(() => {
resolve(89);
}, 1500);
});
return score;
}
function App() {
const [score, setScore] = useState(0);
useEffect(() => {
fetchScore().then(result => {
setScore(result);
});
}, []);
return (
<div onClick={() => setScore((prev) => prev + 5)}>{score}</div>
);
}
export default App;這裡要注意一下,useEffect 的 callback 函數本身不支援 async,因此需要將 async 邏輯封裝成獨立函數(如 fetchScore),再在 useEffect 內部調用。
4. 關於依賴數組
開發模式下Effect會多運行一次,以便發現bug。
- 如果指定了dependencies,則 Effect 在 初始渲染後以及依賴項變更的重新渲染後 執行。
useEffect(() => {
// ...
}, [a, b]); // 如果其中一個不同的話會再次運行- 如果 Effect 沒有使用任何響應式值,則它僅在 "初始渲染後" 運行。
useEffect(() => {
// ...
}, []); // 只會在初始時運行(開發環境下除外)- 如果完全不傳遞dependencies,則 Effect 會在元件的 每次單獨渲染(和重新渲染)之後 運行。
useEffect(() => {
// ...
}); // 每次都會再次運行5. useEffect 特性
我們知道 React 是透過資料流來 render 的,每次 render 後 component 都會有自己當前版本的 props、state 和 event handler 方法。這個特性是理解 useEffect 行為的核心。
export default function Count() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`Current count is: ${count}`);
});
return (
<div>
Count: {count}
<button onClick={() => setCount(count + 1)}>Add count</button>
</div>
);
}A) 每次 Render 都是獨立的快照
第一次 Render (count = 0)
count值為0- useEffect 捕獲到
count = 0 - 點擊按鈕 →
setCount(1)
// 第一次 render 的快照
const count = 0;
useEffect(() => {
console.log(`Current count is: 0`);
});第二次 Render (count = 1)
count值為1- 新的一次函數執行
- useEffect 捕獲到新的值
count = 1
// 第二次 render 的快照
const count = 1;
useEffect(() => {
console.log(`Current count is: 1`);
});第三次 Render (count = 2)
count值為2- 又是一次新的 render
- useEffect 捕獲
count = 2
// 第三次 render 的快照
const count = 2;
useEffect(() => {
console.log(`Current count is: 2`);
});B) 核心概念:閉包(Closure)
每次 render 時,useEffect 都會捕獲當下那次 render 的變數值。
這是因為 JavaScript 的閉包特性:
useEffect(() => {
setTimeout(() => {
console.log(count); // 永遠印出建立此 timeout 當下的 count
}, 1000);
});每個 render 都有自己的變數快照,
後續的非同步操作仍會使用當初被捕獲的值。
C) 常見問題:過時的閉包 (Stale Closure)
當我們給 useEffect 空依賴陣列 [] 時,
表示此 effect 只在掛載 (mount) 時執行一次。
這會導致 effect 內的閉包永遠指向第一次 render 的快照。
實際案例:count的閉包陷阱
function Count() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
console.log(count);
setCount(count + 1);
}, 1000);
}, []);
return <div>{count}</div>;
}
export default Count;第一次 Render (count = 0)
- 組件初次渲染
- useEffect 被執行一次並建立閉包
- 閉包捕獲當前的
count = 0 - 之後不再更新此閉包內容
// 第一次 render 快照
const count = 0;
useEffect(() => {
setInterval(() => {
console.log(count); // 永遠輸出 0
setCount(count + 1); // 永遠執行 setCount(1)
}, 1000);
}, []);[Render #1 Snapshot]
┌────────────────────────────┐
│ count = 0 │
│ │
│ setInterval callback ───┐ │
└────────────────────────────┘
▲ │
│(捕獲此值) │
└──────────────┘
第二次 Render (count = 1)
- React 建立新的快照,但不會重跑 effect(因為依賴為 [])
- 舊定時器仍在執行,使用舊閉包中的
count = 0 - 新的 render 只是建立了新的
count = 1,但未影響舊的閉包
// 第二次 render 快照
const count = 1;
// 舊閉包仍在背景執行:
setInterval(() => {
console.log(0); // 仍是第一次 render 的值
setCount(1); // 不斷嘗試設成 1
}, 1000);[Render #2 Snapshot]
┌────────────────────────────┐
│ count = 1 │
└────────────────────────────┘
[舊閉包]
┌────────────────────────────┐
│ captured count = 0 │ ←─ callback 持續使用此值
│ setCount(count + 1) │
└────────────────────────────┘
第三次 Render (count = 2)
- React 再次產生新的快照(count = 2)
- 但 effect 依然不重跑,因此「setInterval」仍保留第一次的閉包
- 結果畫面顯示 2、3、4…,但
console.log永遠輸出 0
// 第三次 render 快照
const count = 2;
// 舊定時器仍執行第一次建立的閉包
setInterval(() => {
console.log(0); // 仍是舊閉包中的 count
setCount(1);
}, 1000);[Render #3 Snapshot]
┌────────────────────────────┐
│ count = 2 │
└────────────────────────────┘
[舊閉包仍存在]
┌────────────────────────────┐
│ captured count = 0 │
│ ↑ │
│ 仍被 setInterval 持有引用 │
└────────────────────────────┘
結論:
雖然畫面顯示的count已經更新,
但事件或計時器內的閉包仍停留在第一次 render 的值。
解決方案
方法一:加入依賴項
在依賴陣列中加入 count,讓 effect 在每次值改變時重新建立閉包。
useEffect(() => {
const handleKeyPress = (e) => {
if (e.code === 'Space') alert(`Current count is: ${count}`);
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [count]);方法二:使用函數式更新
若 effect 內要基於舊 state 更新,可用函數式寫法。
這樣即使依賴陣列是空的,也不會受到閉包影響。
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // 永遠使用最新值
}, 1000);
return () => clearInterval(timer);
}, []);總結
- 每次 Render 都是獨立的快照:每次函數執行都會建立一組全新的 Props、State 和內部變數。
- useEffect 捕獲當下的值:
useEffect透過 JavaScript 閉包機制,將它誕生時那張快照中的變數值「鎖定」起來。 - 依賴陣列很重要:正確設定 Dependency Array 或是使用 Functional Update 可以確保 Effect 內部總能存取到最新的 State,避免產生過時的閉包 (Stale Closure)。
- 函數式更新是好朋友:當您需要基於前一個 State 更新時,使用 Functional Update 能夠完全避開 Closure 帶來的潛在陷阱。
6. 關於useEffect執行
A) 一般 JS 與 Render 的阻塞執行順序
JS 與渲染是同步阻塞的。每階段需依序完成。
B) useEffect 執行流程
流程重點
- Render Phase:React 建立虛擬 DOM,註冊 useEffect,但不執行
- Commit Phase:畫面更新,React 將變更套用至真實 DOM
- Effect (Async):
useEffectcallback 在 Commit 完成後執行,與渲染分離 - 不會阻塞使用者看到畫面(所以當有二次渲染就會看到閃爍,因為沒有 block)
C) useLayoutEffect 執行流程
useLayoutEffect在 Commit 前同步執行- React 會等它執行完才繪製畫面
- 適合 DOM 尺寸量測、樣式同步等情境
- 若操作太重,可能導致畫面延遲或掉幀
D) 比較
| 比較項目 | useEffect | useLayoutEffect |
|---|---|---|
| 執行時機 | Commit 後(畫面更新後) | Commit 前(畫面繪製前) |
| 執行性質 | 非同步(async) | 同步(blocking) |
| 典型用途 | API 請求、事件綁定、log、副作用 | DOM 尺寸量測、樣式調整 |
| 是否阻塞畫面 | ❌ 不阻塞 | ✅ 會阻塞直到完成 |
| 適用時機 | 不影響 UI 顯示的副作用 | 需要即時反映在畫面前的邏輯 |
| 閃爍風險 | ⚠️ 可能閃爍 | ✅ 不會閃爍 |
E) 觀察
這裡給個example去觀察
import { useEffect, useLayoutEffect, useState, useRef } from "react";
function App() {
const [showEffect, setShowEffect] = useState(false);
const [showLayoutEffect, setShowLayoutEffect] = useState(false);
return (
<div style={{ padding: '20px' }}>
<button onClick={() => setShowEffect(!showEffect)}>
Toggle useEffect
</button>
<button onClick={() => setShowLayoutEffect(!showLayoutEffect)} style={{ marginLeft: '10px' }}>
Toggle useLayoutEffect
</button>
{showEffect && <FlickerExample />}
{showLayoutEffect && <NoFlickerExample />}
</div>
);
}
// ❌ useEffect - 會閃爍
function FlickerExample() {
const [width, setWidth] = useState(0);
const ref = useRef(null);
useEffect(() => {
setWidth(ref.current.offsetWidth);
}, []);
return (
<div ref={ref} style={{ border: '2px solid red', padding: '20px', marginTop: '20px' }}>
<h3>useEffect</h3>
Width: {width}px (會先顯示 0,然後閃爍)
</div>
);
}
// ✅ useLayoutEffect - 不會閃爍
function NoFlickerExample() {
const [width, setWidth] = useState(0);
const ref = useRef(null);
useLayoutEffect(() => {
setWidth(ref.current.offsetWidth);
}, []);
return (
<div ref={ref} style={{ border: '2px solid green', padding: '20px', marginTop: '20px' }}>
<h3>useLayoutEffect</h3>
Width: {width}px (直接顯示正確值)
</div>
);
}
export default App;點擊按鈕能看到width部分數字會跳動。


我們現在都保持關閉,然後使用devtools performance觀察,
useEffect
開始錄製並且點擊useEffect。

Blocking:
Event: click -> Update -> Render (藍色) -> Commit (粉紅) -> Waiting -> Remaining Effects -> Cascading Update (紅色) -> Render (藍色) -> Commit -> Waiting for Paint
執行:
- Render (藍色):React 計算新的 Virtual DOM(尚未寫入真實 DOM)。
- Commit (粉紅):React 寫入真實 DOM,畫面開始準備繪製。
- Waiting:進入「paint 階段」,瀏覽器開始顯示畫面。
- 此時
useEffect尚未執行。畫面會先出現舊狀態(例如 width=0)。
- 此時
- Remaining Effects:paint 之後,開始執行
useEffectcallback。- 在這裡觸發
setState()→ 形成「Cascading Update(紅色)」。
- 在這裡觸發
- Cascading Update (紅色):再次觸發 render → commit。
- 因為這次更新在 paint 之後,React 會重繪。
- Waiting for Paint:第二次 paint,畫面更新成正確寬度。
結論:
- 執行時機: 非同步(在 paint 之後)。
- 是否閃爍: 會(畫面先顯示舊值,再更新)。
- Profiler :
Commit後可見長條的 Waiting → Cascading Update。- 多個 render/commit 循環(代表畫面二次繪製)。
useLayoutEffect
開始錄製並且點擊useLayoutEffect。


Blocking:
Event: click -> Update -> Render (藍色) -> Commit (粉紅) -> Waiting -> Remaining Effects -> Cascading Update (紅色) -> Render (藍色) -> Commit -> Waiting -> Remaining Effects
執行:
- Render (藍色):計算新 Virtual DOM。
- Commit (粉紅):React 寫入真實 DOM。
- Waiting (短暫):在 paint 之前 React 執行 layout effect。
useLayoutEffectcallback 會在這裡被同步呼叫。- 例如
setWidth(ref.current.offsetWidth),立即 setState。
- Remaining Effects / Cascading Update:更新被立即同步處理。
- 第二次 render + commit 仍發生在 paint 前。
- Waiting:瀏覽器此時才進行 paint(畫面已正確顯示)。
結論:
- 執行時機: 同步(在 paint 之前)。
- 是否閃爍: 不會。
- Profiler :
- 所有任務集中在一次大的「blocking commit」。
- 在 Commit 後、Paint 前執行,阻塞 Paint,完成所有更新後才繪製。
- 畫面 paint 之前就完成所有 setState 與 DOM 讀寫。