關於useEffect

Created: 2024/11/12
Updated: 2025/10/26

紀錄react中useEffect,並使用新的devtools觀察


useEffect用來允許我們將component與外部系統同步。

我們常會碰到,要讓一個component與外部系統去同步。像是讓React state去控制一些非React component,又或者是去連接server並顯示等等。Effect允許我們在render結束後執行一些方法,來將component與外部系統同步。也就是會有Effect。

1. 什麼是Effect呢?

如果我們的component是純函數的話,傳回props,就永遠是相同的結果。但裡面有effect的話,每次渲染完後,都會額外執行一些邏輯,所以不會每一次都一樣,這種就叫做Effect(副作用)。

這裡可以看一下一些對於Pure Function和非純函數的說明。

純函數 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 時處理副作用呢?因為這會破壞元件的純粹性,可能導致:

  1. 結果不可預測: 由於外部資料的變動性,Component 行為會變得不穩定且難以追蹤。
  2. 效能阻塞: 副作用的處理會直接拖慢畫面的繪製與更新,導致使用者體驗到卡頓或延遲。
  3. 無限循環: 副作用若不小心觸發 Component 重新 Render,可能造成 Effect 不停重複執行。

為了解決這些問題,React 提供了 useEffect Hook,它是一個專門處理副作用的「隔離區」。

useEffect 的核心機制是:

  1. 延遲執行: 它會將 Effect 的處理延遲到 Component 畫面完成繪製 (Paint) 之後才執行,從根本上避免阻塞 UI 更新。
  2. 清理影響: 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。

  1. 如果指定了dependencies,則 Effect 在 初始渲染後以及依賴項變更的重新渲染後 執行。
useEffect(() => {
  // ...
}, [a, b]); // 如果其中一個不同的話會再次運行
  1. 如果 Effect 沒有使用任何響應式值,則它僅在 "初始渲染後" 運行。
useEffect(() => {
  // ...
}, []); // 只會在初始時運行(開發環境下除外)
  1. 如果完全不傳遞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 ExecutionRender()JS Execution (Next Phase)Render blocking (Sync)Continue after renderJS ExecutionRender()JS Execution (Next Phase)

JS 與渲染是同步阻塞的。每階段需依序完成。

B) useEffect 執行流程

Render PhaseCommit PhaseuseEffect (async)DOM ops, events, API callsSchedule effectExecute after paintRender PhaseCommit PhaseuseEffect (async)

流程重點

  • Render Phase:React 建立虛擬 DOM,註冊 useEffect,但不執行
  • Commit Phase:畫面更新,React 將變更套用至真實 DOM
  • Effect (Async)useEffect callback 在 Commit 完成後執行,與渲染分離
  • 不會阻塞使用者看到畫面(所以當有二次渲染就會看到閃爍,因為沒有 block)

C) useLayoutEffect 執行流程

Render PhaseuseLayoutEffect (sync)Commit/PaintDOM measurements, layout syncExecute before paintBlock until completeRender PhaseuseLayoutEffect (sync)Commit/Paint
  • useLayoutEffectCommit 前同步執行
  • React 會等它執行完才繪製畫面
  • 適合 DOM 尺寸量測、樣式同步等情境
  • 若操作太重,可能導致畫面延遲或掉幀

D) 比較

比較項目useEffectuseLayoutEffect
執行時機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

執行:

  1. Render (藍色):React 計算新的 Virtual DOM(尚未寫入真實 DOM)。
  2. Commit (粉紅):React 寫入真實 DOM,畫面開始準備繪製。
  3. Waiting:進入「paint 階段」,瀏覽器開始顯示畫面。
    • 此時 useEffect 尚未執行。畫面會先出現舊狀態(例如 width=0)。
  4. Remaining Effects:paint 之後,開始執行 useEffect callback。
    • 在這裡觸發 setState() → 形成「Cascading Update(紅色)」。
  5. Cascading Update (紅色):再次觸發 render → commit。
    • 因為這次更新在 paint 之後,React 會重繪。
  6. 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

執行:

  1. Render (藍色):計算新 Virtual DOM。
  2. Commit (粉紅):React 寫入真實 DOM。
  3. Waiting (短暫):在 paint 之前 React 執行 layout effect。
    • useLayoutEffect callback 會在這裡被同步呼叫。
    • 例如 setWidth(ref.current.offsetWidth),立即 setState。
  4. Remaining Effects / Cascading Update:更新被立即同步處理。
    • 第二次 render + commit 仍發生在 paint 前。
  5. Waiting:瀏覽器此時才進行 paint(畫面已正確顯示)。

結論:

  • 執行時機: 同步(在 paint 之前)。
  • 是否閃爍: 不會。
  • Profiler :
    • 所有任務集中在一次大的「blocking commit」。
    • 在 Commit 後、Paint 前執行,阻塞 Paint,完成所有更新後才繪製。
    • 畫面 paint 之前就完成所有 setState 與 DOM 讀寫。