FE Day 9 useEffect:什麼時候該用、什麼時候不該
執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第九篇,正式進入「核心」章節。前八天你學會了元件、props、state、樣式,寫一個按鈕切換數字的小介面已經不成問題;但當畫面開始「讀瀏覽器 API」、「打 API 拿資料」、「訂閱某個事件」、「跟計時器連動」時,你會需要一個「副作用」機制——這就是 useEffect 的工作。useEffect 是 React Hooks 裡最容易「寫得動但寫錯」的一個,本篇要把它從頭到尾講清楚:什麼是副作用、什麼時候該用、什麼時候根本不該用、依賴陣列怎麼處理、cleanup 是什麼、以及為什麼「它不是生命週期方法」。整篇範例都在本機 CPU 跑得起來,不依賴雲端服務。
引言
寫後端的我們對「副作用」並不陌生:一個函式除了回傳值,還做了「改變外界狀態」的事情(例如寫檔、發 HTTP、寫 log),就被稱為有副作用。前端的副作用範圍更廣:直接操作 DOM、呼叫 fetch、訂閱瀏覽器事件、啟動計時器、把資料寫進 localStorage 全部都是。React 函式元件的渲染函式本身應該是純函式(純函式在 Day 5 已經介紹過),所以 React 提供一個專門的「出口」讓你做這些事:useEffect。
useEffect 在 React 社群裡有兩個極端評價:新手把它當萬靈丹,什麼都塞進去;老手則對它敬而遠之,能不用就不用。React 官方文件在 19.x 版後更明確地說:「useEffect 不是生命週期方法,它是把某些事情『同步到某個外部系統』用的機制。」這句話翻成白話是:「如果你做的不是『讓 React 之外的系統跟著元件狀態走』,那 useEffect 就不該出現」。
今天要解開五個常見疑問:第一,什麼是副作用?第二,useEffect 的 callback 什麼時候執行?第三,依賴陣列(dependency array)的規則是什麼?第四,cleanup 是什麼、為什麼一定要寫?第五,什麼情境根本「不該」用 useEffect?最後會用一個完整的「自動儲存到 localStorage 的筆記元件」當範例,把上述五點一次演練完。讀完這篇,你應該能在三秒內判斷某段程式碼「該不該放進 useEffect」,並能正確寫出 cleanup。
副作用與 useEffect 的本質
在 React 函式元件裡,元件函式(render function)每次渲染都會被執行一次。它應該只做一件事:根據 props 與 state 計算出要顯示的 JSX。任何「讀取瀏覽器 API」、「打 API」、「改變非 React 管理的狀態」、「訂閱事件」都不該直接寫在 render 函式裡,否則會造成兩個後果:第一,渲染結果不純,每次跑出來可能不一樣;第二,這些副作用會在每次渲染時都被執行一次,造成重複訂閱、重複請求、重複計時等 bug。
useEffect 就是 React 幫你開的一個「出口」,讓你在 render 之後(或元件被移除之前)做一些「跟外部世界同步」的事情。寫法很簡單:傳一個函式給 useEffect,這個函式會在元件「完成渲染並掛到畫面上」之後執行:
// useEffect 的最小寫法:每次渲染後都執行
import { useEffect } from "react";
function Greeting({ name }: { name: string }) {
useEffect(() => {
// 這段會在 render 之後執行
document.title = `Hello, ${name}`;
});
return <h1>Hello, {name}!</h1>;
}
這段程式把「瀏覽器分頁標題」同步成目前的名字。每次 name 改變、元件重新渲染,useEffect 都會在「畫面真的更新到使用者眼前」之後再執行一次,把 document.title 改成新的值。注意它不是「在渲染時執行」,而是「在渲染完成、React 把結果交給瀏覽器之後」才執行——這個時機差異是 useEffect 跟生命週期方法最大的差別。
為什麼說「不是生命週期方法」
從 class 元件時代過來的工程師會直覺把 useEffect 對應到 componentDidMount、componentDidUpdate、componentWillUnmount。這對應關係「能用但不精確」。官方文件明確表示:useEffect 不是「當元件掛載時執行」、也不是「當元件更新時執行」,而是「當某些值改變時,把這個函式跟那些值同步」。
舉例來說,下面這段程式碼,effect 並不是在「掛載時」跑一次,也不是在「更新時」跑一次,而是在「count 改變時」跑一次:
// 當 count 改變時,把分頁標題同步成目前數字
function Counter({ count }: { count: number }) {
useEffect(() => {
document.title = `目前數字:${count}`;
}, [count]); // 依賴陣列:只有 count 改變時才執行
return <p>目前數字:{count}</p>;
}
心智模型應該是「effect 是把元件跟某個外部系統同步的橋樑」,而不是「元件在生命週期某個階段執行什麼」。這個差別看似抽象,但它會直接影響你怎麼寫 cleanup、怎麼選依賴、怎麼決定要不要把 useEffect 拆開。
依賴陣列:useEffect 的核心規則
useEffect 的第二個參數是「依賴陣列」,這是 useEffect 最容易踩雷的地方。React 用這個陣列決定「什麼時候該重新執行 effect」。三種寫法對應三種行為:
- 不傳第二個參數:每次 render 後都執行(effect 在 mount、update、context 變化時都會跑)。
- 傳空陣列
[]:只有「第一次掛載」和「元件被移除」時會執行,後續 re-render 都不跑。 - 傳帶值的陣列
[a, b]:只有當陣列裡的值「跟上一次相比有變化」時才執行。
React 內建了一個 ESLint 規則 react-hooks/exhaustive-deps(Day 2 設定 ESLint 時已經啟用),它會在 effect 函式裡用到某個變數、卻沒把它放進依賴陣列時跳警告。這個警告看起來囉嗦,但它能擋下 90% 的 useEffect bug。寫 React 的紀律是:不要試圖關掉它。
以下是三種寫法的對照範例:
// 三種依賴陣列的行為對照
import { useEffect, useState } from "react";
function Demo() {
const [count, setCount] = useState(0);
const [name, setName] = useState("訪客");
// 寫法 1:不傳依賴:每次 render 後都跑
useEffect(() => {
console.log("每次 render 後都跑(count、name 任一改變)");
});
// 寫法 2:空陣列:只在 mount 與元件被移除時跑
useEffect(() => {
console.log("只在元件掛載時跑一次");
return () => console.log("元件被移除時跑 cleanup");
}, []);
// 寫法 3:明確列出依賴:count 改變時才跑
useEffect(() => {
console.log(`count 變成 ${count} 時跑`);
document.title = `目前 ${count}`;
}, [count]);
return (
<div>
<p>{name}:{count}</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
<button onClick={() => setName("工程師")}>改名</button>
</div>
);
}
在這段程式裡,「寫法 3」只會在 count 改變時印 log、改分頁標題;當 name 改變時,這個 effect 不會重新執行,因為 name 不在它的依賴裡。這就是 useEffect「精準同步外部系統」的能力——你告訴它「我只在乎 count」,它就不會在無關的 state 變動時打擾你。
物件與函式當依賴的陷阱
依賴陣列是用「淺比較(Object.is)」來判斷變化的。這帶來一個新手常踩的雷:如果你在 render 函式裡「現場建立」一個物件或函式當依賴,它每次 render 都是新的參考,effect 就會被觸發:
// 反例:每次 render 都建立新的 options 物件,effect 會無限觸發
function SearchBox() {
const [keyword, setKeyword] = useState("");
const options = { limit: 10, lang: "zh-TW" }; // 每次 render 都是新物件
useEffect(() => {
fetch(`/api/search?q=${keyword}`, options);
}, [keyword, options]); // options 每次都「變」,等於每次都打 API
return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
}
修法有三種:把物件宣告移到元件外(變成模組常數)、用 useMemo 包起來、或在 effect 內部只取需要的純量。useMemo 會在 Day 10 講到,今天先知道「現場建立物件當依賴會壞掉」就夠了。
cleanup:把訂閱、計時器、事件清乾淨
useEffect 的 callback 可以「回傳另一個函式」,那個函式被稱為 cleanup。React 會在「下一次 effect 執行前」以及「元件被移除時」呼叫 cleanup。它的用途是「把你先前打開的東西關掉」:取消訂閱、清除計時器、移除事件監聽、關閉 WebSocket、中止 fetch。忘記寫 cleanup 是 React 專案最常見的記憶體洩漏來源。
cleanup 的執行時機有兩個。第一個是「下次 effect 重新執行前」:當依賴改變、effect 要再跑一次時,React 會先跑上一次的 cleanup,再執行新的 effect。這能保證「外部系統不會同時被兩個 effect 訂閱」。第二個是「元件被移除時」:元件從畫面消失時,cleanup 會被執行一次,把資源釋放掉。
以下是 cleanup 的標準寫法範例:
// cleanup 標準寫法:訂閱事件 + 元件被移除時取消
import { useEffect, useState } from "react";
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
// 註冊瀏覽器事件:當視窗寬度改變時更新 state
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
// cleanup:元件被移除時(或下次 effect 重跑前)移除監聽
return () => {
window.removeEventListener("resize", handleResize);
};
}, []); // 空陣列:這個 effect 只在 mount/移除時跑一次
return <p>目前寬度:{width}px</p>;
}
如果忘記寫 removeEventListener,這個元件被反覆掛載與移除時,每次都會留下一個監聽,最後視窗 resize 一次就更新好幾次 state——這是經典的記憶體洩漏。記住一個口訣:「只要 effect 內部做了『加』,cleanup 就必須做對應的『減』:加事件就移除事件、加計時器就清除計時器、訂閱就取消訂閱、打 fetch 就 abort。」
計時器與 abort 的 cleanup
另一個常見的情境是計時器與 fetch 中止。AbortController 是瀏覽器原生 API,可以用來中斷進行中的 fetch;React 文件在資料取得章節(Day 14 會講)會大量使用:
// 計時器與 fetch 的 cleanup
import { useEffect, useState } from "react";
function PriceTicker({ symbol }: { symbol: string }) {
const [price, setPrice] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
const ctrl = new AbortController();
async function fetchPrice() {
const res = await fetch(`/api/price/${symbol}`, { signal: ctrl.signal });
const data = await res.json();
if (!cancelled) setPrice(data.price); // 用 cancelled 旗標避免寫到元件被移除後的 state
}
fetchPrice();
const id = setInterval(fetchPrice, 5000); // 每 5 秒更新
return () => {
cancelled = true; // 標記「這個 effect 的後續更新都不該生效」
ctrl.abort(); // 中斷進行中的 fetch
clearInterval(id); // 清除計時器
};
}, [symbol]); // symbol 改變時,重新訂閱
return <p>{symbol} 價格:{price ?? "載入中"}</p>;
}
這段範例把三件事一次示範完:cleanup 內設 cancelled 旗標避免在元件被移除後還呼叫 setState、用 AbortController 中斷還沒回來的 fetch、用 clearInterval 清掉計時器。React 19 之後即使你在元件被移除後呼叫 setState 也不再跳警告(19 之前會跳「Can't perform a React state update on an unmounted component」),但「不更新到已被移除的元件」仍然是正確的設計,cancelled 旗標繼續推薦使用。
什麼時候「不該」用 useEffect
這是 useEffect 章節最重要的觀念:很多時候新手把 useEffect 當萬靈丹,但其實根本不需要。以下是四個常見的「不該用」情境。
第一種:「只是要把某個 state 衍生出另一個值」。這不該用 useEffect,因為 state 改變時 render 函式本身就會重新跑,直接在 render 裡計算就好:
// 反例:用 useEffect 衍生狀態(多一次 render、還可能抓到舊值)
function Cart({ items }: { items: Item[] }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]);
return <p>總金額:{total}</p>;
}
// 正解:直接在 render 計算
function Cart({ items }: { items: Item[] }) {
const total = items.reduce((sum, i) => sum + i.price, 0);
return <p>總金額:{total}</p>;
}
反例的問題是「兩次 render」:第一次 render 時 total 是 0,使用者會看到短暫的 0;effect 跑完後 setTotal 觸發第二次 render,總金額才正確。正解在 render 函式裡就算出 total,沒有任何延遲。
第二種:「響應使用者事件而做的事」。例如「按下按鈕時送出表單」,這該寫在 onClick 裡,不該寫在 useEffect:
// 反例:用 useEffect 監聽某個 state 變化去打 API
function SubmitButton() {
const [shouldSubmit, setShouldSubmit] = useState(false);
useEffect(() => {
if (shouldSubmit) fetch("/api/submit", { method: "POST" });
}, [shouldSubmit]);
return <button onClick={() => setShouldSubmit(true)}>送出</button>;
}
// 正解:直接在 onClick 裡打 API
function SubmitButton() {
return <button onClick={() => fetch("/api/submit", { method: "POST" })}>送出</button>;
}
反例多繞了一圈「設 state → 等 render → effect 才跑」才打到 API,徒增複雜度也浪費一次 render。
第三種:「重置某個 state 當 prop 改變」。React 19 提供一個專門的 Key 重置機制(用 key 讓 React 重新掛載元件實例),但更常見的是把這個責任放在「真正擁有狀態的上層元件」。如果某個 prop 改變時你想清空某個內部 state,那個 state 很可能「不屬於這個元件」——把它提升到上層去管,或改用派生 state。
第四種:「可以直接在事件處理內做的事」。例如使用者點選某個選項時把 ID 寫進 localStorage,這該寫在 onClick,不該寫在 effect 裡「當 id 改變時同步到 localStorage」。差別在於「事件 vs 狀態」:事件觸發就該即時處理;只有「狀態要跟外部系統同步」才需要 effect。
何時「真的該用」useEffect
把上面的反面拿掉之後,useEffect 真正該出現的場景就清楚了:當你的元件需要跟某個「不歸 React 管」的外部系統同步時。例如:訂閱瀏覽器事件、跟 WebSocket 連線、把資料寫進第三方動畫庫、把狀態同步到 localStorage 之外的非 React 儲存體、與第三方命令式 API 整合(地圖、視訊播放器)。如果你的程式碼沒有觸碰「React 之外的系統」,多半不該用 useEffect。
完整實作:自動儲存到 localStorage 的筆記元件
把上面的觀念整合起來,做一個「自動儲存到 localStorage」的筆記元件。功能:使用者輸入文字時,每次內容改變就自動存進 localStorage;元件被移除時不做額外事(資料已經存過了)。這是一個經典的「同步到 React 外部系統」案例,剛好示範依賴陣列 + cleanup 的搭配。
先安裝依賴(在既有 Vite 專案裡裝 zod 做驗證,TypeScript 型別則來自 React 內建):
# 如果還沒裝過 zod,可以加這行;本篇主要靠 React 內建型別
pnpm add zod
接著寫元件本體。為了讓程式碼結構清楚,把它分成「讀取」、「寫入」、「元件」三段:
// src/hooks/usePersistentDraft.ts
// 自訂 Hook:把字串同步到 localStorage
// 這個 Hook 會在後續文章陸續擴充,這裡先給出最小版本
import { useEffect, useState } from "react";
const STORAGE_KEY = "note:draft";
export function usePersistentDraft() {
// 第一次 render 時從 localStorage 讀初值(延遲初始化)
const [text, setText] = useState<string>(() => {
if (typeof window === "undefined") return "";
return window.localStorage.getItem(STORAGE_KEY) ?? "";
});
// 每次 text 改變時同步寫回 localStorage
useEffect(() => {
window.localStorage.setItem(STORAGE_KEY, text);
}, [text]);
// 不需要 cleanup:localStorage.setItem 是一次性寫入,沒有「訂閱」或「計時器」需要釋放
// 但若改成 IndexedDB 連線或 WebSocket,就必須寫 cleanup
return [text, setText] as const;
}
上面的 useEffect 有兩個重點:第一,依賴陣列只有 [text],所以只有 text 改變時才會重跑;其他 state 變動(例如之後要加的字數統計)不會觸發寫入。第二,這個 effect 不需要 cleanup——localStorage.setItem 是一次性寫入,沒有「釋放資源」的動作。如果未來改成 IndexedDB 連線(會打開 transaction),那就要在 cleanup 裡把 transaction 結束。
接著寫元件本身,把 usePersistentDraft 用起來:
// src/components/NoteEditor.tsx
// 自動儲存筆記元件:輸入時即時存到 localStorage
import { usePersistentDraft } from "../hooks/usePersistentDraft";
export function NoteEditor() {
const [text, setText] = usePersistentDraft();
return (
<div className="mx-auto max-w-xl space-y-2 p-4">
<label htmlFor="note" className="block text-sm font-medium text-slate-700">
草稿(自動儲存)
</label>
<textarea
id="note"
value={text}
onChange={(e) => setText(e.target.value)}
rows={6}
className="w-full rounded border border-slate-300 p-2 text-sm"
placeholder="開始打字,內容會自動存到 localStorage"
/>
<p className="text-xs text-slate-500">
目前 {text.length} 字(重整後仍會保留)
</p>
</div>
);
}
這個元件展示了「useEffect 用來跟外部系統同步」的核心場景:localStorage 是 React 管不到的儲存體,我們透過 useEffect 在 text 改變時同步過去。沒有 effect 的話,每次 setText 觸發的 re-render 不會自動把資料寫進 localStorage,使用者重整頁面就會看到空白。
把元件掛到 App 並啟動開發伺服器:
// src/App.tsx
import { NoteEditor } from "./components/NoteEditor";
export default function App() {
return (
<main className="min-h-screen bg-slate-50">
<NoteEditor />
</main>
);
}
// 終端機:pnpm dev
// 輸出:VITE v7.x.x ready in xxx ms
// 輸出:Local: http://localhost:5173/
打開瀏覽器試打幾個字,重新整理頁面,會發現字還在。這個行為已經驗證了 useEffect 的同步效果。如果你打開 React DevTools 並裝上 Profiler,可以看到 text 每次改變都會觸發一次 effect;不過這次 effect 內部只做一次 setItem,不會造成額外 re-render,效能上完全沒問題。
常見錯誤與踩雷
第一個最常見的踩雷是「忘記寫 cleanup」。例如在 effect 裡加了 window.addEventListener 卻沒在 cleanup 移除,元件被反覆掛載與移除(典型情境:路由切換)後,每次都會多一個監聽,最後一個 resize 事件可能更新好幾次 state。記住「有加就有減」這個口訣:加事件就要移除事件、加計時器就要清除計時器、訂閱就取消訂閱。
第二個踩雷是「依賴陣列漏寫」。例如 effect 裡用到 props.userId 卻沒把它放進依賴,於是元件在 userId 改變時不會重新打 API,仍在使用舊的 userId。這時 ESLint 的 react-hooks/exhaustive-deps 會跳警告,請不要 disable 它。如果你看到警告覺得「我知道我在幹嘛」,請先停下來確認一次——八成是 bug。
第三個踩雷是「在 effect 裡呼叫 setState 卻造成無限迴圈」。最常見的寫法是「依賴陣列裡放了某個每次 render 都是全新參考的物件」,例如 [{ a: 1 }] 或 [() => doSomething()]。這會讓 effect 每次 render 都「認為依賴變了」,於是無限觸發。修法是用 useMemo、useCallback,或把物件宣告搬到元件外。
第四個踩雷是「用 useEffect 處理 SSR(伺服器端渲染)會讀不到的 API」。例如直接呼叫 window.localStorage,在 Next.js 伺服器渲染時會出錯(沒有 window)。修法是把存取包在 typeof window !== "undefined" 判斷內,或把元件標記為 client component(Day 20 會講)。今天範例已經預先用 typeof 判斷過,實際搬到 Next.js 時不會壞。
第五個踩雷是「用 useEffect 模擬 componentDidMount」。常見情境是「我想在元件掛載時打一次 API」,於是寫 useEffect(fn, [])。這能跑,但不是最佳寫法——React 官方與社群都推薦用專門的資料取得函式庫(Day 14 會教 TanStack Query),它幫你處理快取、重試、競態、stale 狀態,比手寫 useEffect 健壯得多。
效能與實務提醒
useEffect 的執行時機是「render 完之後」,這代表 effect 不會阻塞畫面渲染。對於使用者來說,即使 effect 內部做了一點重的 IO(例如 fetch),畫面還是會先顯示出來。這是 React 的設計選擇:把「同步的 DOM 更新」放在最優先,把「可能要花時間的副作用」放到 render 之後。
不過這個特性也帶來副作用:第一次 render 時 effect 還沒跑完,所以畫面上的 state 可能是「舊的」或「空值」。這對資料取得元件特別重要——你必須設計好「載入中」、「錯誤」、「完成」三種狀態,避免使用者看到一片空白或閃爍的內容。Day 14 會專門展開這個主題。
另一個實務提醒是「把相關的 effect 拆開」。與其在元件裡寫一個巨大的 useEffect 把所有副作用都塞進去,不如按照「要同步的外部系統」拆成多個小 effect。例如一個元件要同時「訂閱瀏覽器事件」與「打 API 拿資料」,就應該寫成兩個 useEffect,不要混在一起。這樣每個 effect 的依賴都很明確、除錯時也容易定位。
最後一個提醒是 StrictMode 的雙重呼叫。在開發模式下,React 的 StrictMode 會刻意把每個元件 mount 兩次,目的是抓出「不純」的 effect——例如忘了寫 cleanup、或 cleanup 寫錯。如果你的 cleanup 寫得不嚴謹(例如只呼叫了 removeEventListener 但沒清計時器),在 StrictMode 下會被立刻抓出來。當你看到「某個 effect 跑兩次、cleanup 也跑兩次」的 log,別慌,這是開發模式的正常行為,正式上線後只會跑一次。
小結
今天我們把 useEffect 的本質、依賴陣列規則、cleanup 寫法、以及「什麼時候不該用」一次攤開來了。重點回顧:useEffect 是把 React 狀態跟「外部系統」同步的橋樑,不是生命週期方法;依賴陣列用淺比較決定是否重新執行,記得用 ESLint exhaustive-deps 規則把漏寫的依賴擋下;cleanup 在下次 effect 執行前與元件被移除時執行,「有加就有減」是必須遵守的紀律;很多新手以為需要 useEffect 的場景其實可以直接在 render 或事件裡完成,不需要 effect。我們也實作了一個會自動儲存到 localStorage 的筆記元件,示範了真實的「同步到 React 外部系統」場景。明天 Day 10 會在這個基礎上介紹「自訂 Hook」,把 useEffect 的邏輯抽成可重用的函式,解決「兩個元件都需要自動儲存草稿」這類需求。
結語
明天,我們會把 useEffect 的邏輯抽成「自訂 Hook」——一種 React 內建的「邏輯重用」機制。我們會把今天的 usePersistentDraft 擴充成支援 schema 驗證的版本,再用它做一個「同步多個欄位」的範例,體會「Hook 讓元件只剩畫面、邏輯全部集中到 Hook」的清爽分工。記得今天的 NoteEditor 與 usePersistentDraft 先留著,明天會在同一個專案上把它升級成更通用的版本。
延伸資源
- React 官方〈Synchronizing with Effects〉(19.x,2026 年 3 月):
https://react.dev/learn/synchronizing-with-effects - React 官方〈You Might Not Need an Effect〉(19.x):
https://react.dev/learn/you-might-not-need-an-effect - React 官方〈useEffect 參考〉(19.x):
https://react.dev/reference/react/useEffect - MDN〈AbortController〉:
https://developer.mozilla.org/zh-TW/docs/Web/API/AbortController - React Hooks ESLint 插件(exhaustive-deps 規則):
https://www.npmjs.com/package/eslint-plugin-react-hooks
留言
張貼留言