我們解析了 useState 的底層單向鏈表與 State 的不可變性。今天我們要討論 React 中被最多次誤用、也最容易引發 Race Condition 的 Hook:useEffect。
許多人習慣用「類別組件 (Class Component) 的生命週期(componentDidMount / componentDidUpdate / componentWillUnmount)」來思考 useEffect,這其實是理解它的最大障礙。useEffect 的核心本質是「與外部系統進行同步 (Synchronization)」,而不是處理生命週期事件!
React 的渲染(Render)應該是純粹的 (Pure)——給定相同的 State 與 Props,就回傳相同的 JSX,不該包含任何副作用(Side Effects,如發送 API、註冊 Event Listener、手動操作 DOM)。
useEffect 的真正使命是:「在 React 渲染完成(Commit 階段)後,讓組件狀態與 React 以外的外部系統(如 WebSocket、Timer、DOM)保持同步。」
State 變動 ──► React 渲染 UI (Pure) ──► 畫面更新 (Commit) ──► 執行 useEffect (與外部同步)
useEffect(effectFunction, deps) 的第二個參數是依賴陣列,它控制了 Effect 的執行時機:
沒給 deps:useEffect(() => {})
時機:每一次 Render 完成後都會執行。極易造成死迴圈!
給予空陣列 []:useEffect(() => {}, [])
時機:僅在 Mount(初次渲染完成) 後執行一次。
給予特定依賴 [a, b]:useEffect(() => {}, [a, b])
時機:Mount 時執行一次;後續只要 a 或 b 的記憶體參考發生改變,就會重新執行。
核心鐵則 (Linter Rule):只要在 Effect 內部有讀取到的 Props、State 或內部變數,全部都必須誠實填入依賴陣列中!隱瞞依賴會引發 Stale Closure(過時閉包)陷阱。
useEffect 可以回傳一個函數(Cleanup Function),這是維護外部同步狀態不可或缺的防線:
useEffect(() => {
// 1. Setup(設定):建立同步連接
const socket = connectToChatRoom(roomId);
// 2. Cleanup(清除):中斷舊連接
return () => {
socket.disconnect();
};
}, [roomId]);
Cleanup 的真實執行時機:
許多人以為 Cleanup 只會在「組件 Unmount(銷毀)」時執行,這是巨大的誤解!
真實情況:當 roomId 改變觸發重新渲染時,React 會先執行「上一次 Effect 回傳的 Cleanup」(斷開舊房間),隨後才執行「全新的 Effect Setup」(連接新房間)!
[roomId 改變為 'room2'] ──► 執行 room1 的 Cleanup ──► 執行 room2 的 Effect Setup
在 API 請求中,若使用者快速切換頁面或搜尋條件,先發出的請求可能比後發出的請求晚回傳,導致舊資料覆蓋新資料(Race Condition)。
利用 useEffect 的 Cleanup 閉包旗標,可以完美解決此問題:
useEffect(() => {
let isCancelled = false; // 閉包旗標
async function fetchData() {
const data = await apiGetArticle(articleId);
// 如果在 API 回傳前 articleId 已改變,isCancelled 會被 Cleanup 設為 true
if (!isCancelled) {
setArticle(data);
}
}
fetchData();
return () => {
isCancelled = true; // 當 articleId 變更或組件 Unmount 時,標記上一次請求無效
};
}, [articleId]);