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 階段發生在:

  1. 組件第一次掛載(mount)時
  2. 組件的 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 裡印出 loglog 數量爆炸、除錯混亂
在 render 裡直接操作 DOMReact 可能還沒動到 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
第一次倒數1010
第二次倒數910 ← 錯了!
第三次倒數89 ← 又錯了

這就是閉包造成的「過時資料」問題,會導致邏輯錯亂、跳秒或失敗更新。

✅ 正解:使用 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 的好幫手

Similar Posts

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *