useEffect
useEffect 是 React 中用於處理副作用(side effects)的 hook。透過 useEffect,我們可以執行一些帶有副作用的操作,例如資料抓取、訂閱事件等。
什麼是副作用函式?什麼是純函式?
純函式(Pure Function)
純函式是指在相同的輸入下,總是會回傳相同的輸出,且不會對外部狀態產生任何影響的函式,這也意味著純函式的行為是可預測的。純函式不會修改外部狀態也不會依賴外部可變狀態。
副作用函式(Side Effect Function)
副作用函式則是指在執行過程中會影響外部狀態或依賴外部可變狀態的函式。函式行為的可預測性降低,但是副作用不一定是壞事,有時候副作用的效果才是我們想要的。
常見的帶有副作用的操作包括:
- 資料抓取(fetching data)
- 操作 DOM
- 操作 storage(localStorage、sessionStorage)
- 計時器(setTimeout、setInterval)
let globalVar = 0;
function effectFunction(x: number): number {
globalVar += x; // 修改外部變數
localStorage.setItem('globalVar', `${globalVar}`); // 修改 localStorage
fetch('/api/data').then((res) => {/* ... */}); // 發送網路請求
document.querySelector('#app').textContent = `Value: ${globalVar}`; // 修改 DOM element
return globalVar;
}useEffect 的使用方式
useEffect(setup, deps?)setup: 一個帶有副作用的函式,並且可以選擇性地回傳一個清理函式(cleanup function)。元件掛載時會執行setup,依賴項改變時會先執行cleanup,然後再執行setup,在元件卸載時也會執行cleanup。deps?: 一個可選的依賴陣列,包含setup函式中所使用到的響應式值(state、props 等),或是自己在元件中定義的變數或函式。如果不傳入deps,則每次元件重新渲染時都會執行setup。
基本使用
操作 DOM
import { useEffect } from 'react';
function App() {
const dom = document.getElementById('data');
console.log(dom); // null
useEffect(() => {
const data = document.getElementById('data');
console.log(data); // <div id="data">Hello World</div>
}, []);
return (
<>
<div id="data">Hello World</div>
</>
);
}打 API 抓資料
useEffect(() => {
fetch('https://api.example.com/data')
.then((response) => response.json())
.then((data) => {
console.log(data);
});
}, []);執行時機
- 元件掛載時(Mounting):當元件第一次被渲染到 DOM 時,會執行
setup函式。 - 依賴項改變時(Updating):當
deps陣列中的任一值發生變化時,會先執行cleanup然後再執行setup。 - 元件卸載時(Unmounting):當元件從 DOM 中被移除時,會執行
cleanup函式。
如果沒有為 useEffect 提供依賴陣列,則 effect 中的副作用函式會在每次元件渲染完成後執行。例如:
import { useEffect, useState } from 'react';
function Count() {
const [count, setCount] = useState(0);
const add = () => {
setCount((c) => c + 1);
};
// 這裡每次輸出的都是前一次的舊值
console.log(document.querySelector('h1')?.innerHTML);
// 在元件每次渲染完成之後,都會重新執行 effect 中的副作用函式
useEffect(() => {
console.log(document.querySelector('h1')?.innerHTML);
});
return (
<>
<h1>目前的 count 值:{count}</h1>
<button onClick={add}>+1</button>
</>
);
}有依賴陣列的情況
如果 deps 陣列為空陣列 [],則 effect 中的副作用函式只會在元件首次渲染完成後執行一次,之後當元件重新渲染時不會再執行。
將剛才的範例修改如下:
useEffect(() => {
console.log(document.querySelector('h1')?.innerHTML);
});
}, []); 現在不論我們點擊多少次按鈕,useEffect 中的 console 只會執行一次。
如果想要有條件地去觸發 useEffect 的重新執行,則需要在 deps 陣列放入指定的依賴值。
React 會在每次元件渲染完成後,去比對前後兩次的依賴值是否有改變,只要有任何一個依賴值改變了,都會觸發 useEffect 的重新執行。反之,如果所有依賴值都沒有改變,則不會觸發 useEffect 的重新執行。
我們接著調整剛才的範例,讓 useEffect 只在 count 改變時才執行,而 flag 不論怎麼改變都不會觸發 useEffect:
function Count() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
const add = () => {
setCount((c) => c + 1);
};
useEffect(() => {
console.log(document.querySelector('h1')?.innerHTML);
}, [count]);
return (
<>
<h1>目前的 count 值:{count}</h1>
<p>Flag 為 {`${flag}`}</p>
<button onClick={add}>+1</button>
<button onClick={() => setFlag((f) => !f)}>Toggle Flag</button>
</>
);
}注意
不建議將物件型別作為 useEffect 的依賴值,因為 React 內部是使用 Object.is() 來比較前後兩次的依賴值是否相等,而如果是一般物件型別的話,每次重新渲染時都會產生一個新的物件參考,導致 useEffect 每次都被觸發執行,這樣就失去了使用依賴陣列的意義。
清理副作用(Cleanup)
如果元件裡面使用了訂閱事件、計時器等副作用操作,通常需要在元件卸載或依賴值改變時進行清理,避免 memory leak 或不必要的效能消耗。
清除定時器
我們來看一個範例,當 name 值改變時,useEffect 會設定一個計時器,在 3 秒後更新 greeting 狀態。如果在 3 秒內 name 再次改變,則需要清除之前的計時器,避免多個計時器同時存在。
import { useState, useEffect } from 'react';
function App() {
const [name, setName] = useState('Sheep');
const [greeting, setGreeting] = useState('');
useEffect(() => {
const timer = setTimeout(() => {
setGreeting(`Hello, ${name}!`);
}, 3000);
return () => {
clearTimeout(timer);
};
}, [name]);
return (
<div>
<label>
輸入名字:
<input
value={name}
onChange={(e) => {
setName(e.target.value);
}}
/>
</label>
<p>{greeting}</p>
</div>
);
}元件卸載時清理
我們來看另一個範例,當元件掛載時掛上了一個記錄滑鼠游標位置的事件偵聽器,當元件卸載時需要將事件偵聽器移除。
import { useEffect, useState } from 'react';
function MouseInfo() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
// 定義滑鼠移動的事件處理函式
const mouseMoveHandler = (e: MouseEvent) => {
console.log({ x: e.clientX, y: e.clientY });
setPosition({ x: e.clientX, y: e.clientY });
};
// 元件在首次掛載時,註冊滑鼠移動事件偵聽器
window.addEventListener('mousemove', mouseMoveHandler);
// 回傳一個清理函式,在元件卸載時移除事件偵聽器
return () => window.removeEventListener('mousemove', mouseMoveHandler);
}, []);
return (
<div>
<p>滑鼠游標位置:{`X: ${position.x}, Y: ${position.y}`}</p>
</div>
);
}
function App() {
const [flag, setFlag] = useState(false);
return (
<div>
<button onClick={() => setFlag(!flag)}>Toggle Mouse Info</button>
{flag && <MouseInfo />}
</div>
);
}倒計時工具範例
一個非常常見也是面試常考的情境是實作一個倒數計時器(countdown timer)。使用者呼叫一個 useCountdown 的 custom hook,並傳入初始的秒數,然後這個 hook 會回傳目前剩餘的秒數以及一個禁用狀態,表示倒數計時是否結束。
import { useEffect, useState } from 'react';
export function useCountdown(initialSeconds = 10): [number, boolean] {
initialSeconds = Math.round(Math.abs(initialSeconds)) || 10;
const [remaining, setRemaining] = useState(initialSeconds);
const [isFinished, setIsFinished] = useState(false);
useEffect(() => {
if (remaining <= 0) return;
const timer = setTimeout(() => {
setRemaining((r) => {
if (r <= 1) {
setIsFinished(true);
return 0;
}
return r - 1;
});
}, 1000);
return () => clearTimeout(timer);
}, [remaining]);
return [remaining, isFinished];
}import { useCountdown } from '@/hooks/useCountdown';
function Countdown() {
const [remaining, isFinished] = useCountdown(5);
const handleClick = () => {
console.log('送出');
};
return (
<>
<button disabled={!isFinished} onClick={handleClick}>
{isFinished ? '確認' : `請詳閱條款 (${remaining} 秒)`}
</button>
</>
);
}處理非同步操作中的競爭條件
下面有一個情境是,使用者在搜尋框中輸入 id 來取得使用者資料。假設使用者快速地輸入了多個 id,導致多個非同步的 fetch 請求同時進行,這時候可能會發生競爭條件(race condition),最終顯示的資料並不是最後一次輸入的 id 所對應的資料。
import { useEffect, useState } from 'react';
interface UserData {
name: string;
id: number;
email: string;
username: string;
phone: string;
website: string;
}
function SearchUser() {
const [userId, setUserId] = useState(1);
const [userData, setUserData] = useState<UserData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUser = async () => {
setLoading(true);
try {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
if (!res.ok) {
throw new Error('Network response was not ok');
}
const data: UserData = await res.json();
setUserData(data);
setError(null);
} catch (err) {
setError(`Error: ${(err as Error).message}`);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUserId(Number(e.target.value));
};
return (
<div>
<h1>Search User</h1>
<label htmlFor="userId">
User ID:
<input id="userId" type="number" value={userId} onChange={handleChange} />
</label>
{loading && <p>Loading...</p>}
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
{userData && (
<div>
<h2>User Details</h2>
<p>Name: {userData.name}</p>
<p>ID: {userData.id}</p>
<p>Email: {userData.email}</p>
<p>Username: {userData.username}</p>
<p>Phone: {userData.phone}</p>
<p>Website: {userData.website}</p>
</div>
)}
</div>
);
}Race Condition 的影響
- 狀態不一致:最後顯示的資料不一定對應到最後一次輸入。
- 額外的生命週期問題:如果元件 unmount 之後請求才結束,又在回傳結果後更新 state,會造成「嘗試更新已卸載元件」的情況,因此我們也會在 cleanup 階段中止請求,讓它跟著 effect 生命週期結束。
解決方案 - 利用 cleanup 機制
我們可以利用 useEffect 的 cleanup 機制配合 AbortController 來解決這個問題。每次發起新的 fetch 請求時,都會中止前一次的請求,確保只有最後一次的請求會更新狀態。
useEffect(() => {
const controller = new AbortController();
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, {
signal: controller.signal,
});
if (!res.ok) {
throw new Error('Network response was not ok');
}
const data: UserData = await res.json();
setUserData(data);
} catch (err) {
const error = err as Error;
// 如果是主動中止 (例如 userId 改變或元件卸載),就直接忽略
if (error.name === 'AbortError') return;
setError(`Error: ${error.message}`);
setUserData(null); // 視需求決定要不要清掉舊資料
} finally {
setLoading(false);
}
};
fetchUser();
// userId 改變或元件卸載時中止請求
return () => {
controller.abort();
};
}, [userId]);注意事項
- 不要在 useEffect 中改變依賴陣列中的值,這會導致無限迴圈。
- 多個不同功能的副作用應該拆分成多個 useEffect,而不是放在同一個 useEffect 中。
- 使用之前應該要先想想是否真的需要 useEffect,有時候可以透過其他方式達成相同的效果,應該將其視為最後的手段。(延伸閱讀:You Might Not Need an Effect)