10.1 什麼是副作用 (Side Effects)?
副作用是指在渲染過程之外發生的操作,例如:
| 副作用類型 | 範例 |
|---|---|
| 資料獲取 | 呼叫 API、讀取資料庫 |
| 訂閱 | WebSocket、事件監聽 |
| DOM 操作 | 修改 document.title、操控 DOM |
| 計時器 | setTimeout、setInterval |
| 儲存 | 寫入 localStorage |
| 日誌 | console.log、錯誤追蹤 |
useEffect 讓你在函式元件中執行這些副作用。
10.2 useEffect 基本語法
import { useEffect } from 'react';
useEffect(() => {
// 副作用程式碼
return () => {
// 清除函式(可選)
};
}, [依賴1, 依賴2]); // 依賴陣列
三種使用模式
import { useState, useEffect } from 'react';
function EffectPatterns() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// 模式 1:每次渲染都執行(不傳依賴陣列)
useEffect(() => {
console.log('每次渲染都會執行');
});
// 模式 2:只在掛載時執行一次(空依賴陣列)
useEffect(() => {
console.log('只在元件掛載時執行一次');
}, []);
// 模式 3:在特定值改變時執行
useEffect(() => {
console.log(`count 改變了:${count}`);
}, [count]);
// 依賴多個值
useEffect(() => {
console.log(`count 或 name 改變了`);
}, [count, name]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<input value={name} onChange={(e) => setName(e.target.value)} />
</div>
);
}
10.3 useEffect 生命週期
import { useState, useEffect } from 'react';
function LifecycleDemo() {
const [count, setCount] = useState(0);
useEffect(() => {
// componentDidMount + componentDidUpdate
console.log(`[Effect] count = ${count}`);
// componentWillUnmount(清除函式)
return () => {
console.log(`[Cleanup] 清除舊的 effect, 舊 count = ${count}`);
};
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
// 執行順序:
// 首次渲染:[Effect] count = 0
// 點擊 +1:[Cleanup] 清除舊的 effect, 舊 count = 0
// [Effect] count = 1
// 點擊 +1:[Cleanup] 清除舊的 effect, 舊 count = 1
// [Effect] count = 2
// 元件卸載:[Cleanup] 清除舊的 effect, 舊 count = 2
生命週期對照表
| Class Component | useEffect Hook |
|---|---|
componentDidMount |
useEffect(() => {}, []) |
componentDidUpdate |
useEffect(() => {}, [dep]) |
componentWillUnmount |
useEffect(() => { return () => {} }, []) |
10.4 常見使用場景
場景 1:修改頁面標題
function DocumentTitle({ title }) {
useEffect(() => {
const prevTitle = document.title;
document.title = title;
return () => {
document.title = prevTitle;
};
}, [title]);
return <h1>{title}</h1>;
}
場景 2:事件監聽
function WindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handleResize);
// 清除:移除事件監聽器
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // 只在掛載時綁定一次
return (
<p>視窗大小:{size.width} × {size.height}</p>
);
}
場景 3:計時器
function Timer() {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
if (!isRunning) return;
const intervalId = setInterval(() => {
setSeconds((prev) => prev + 1);
}, 1000);
// 清除:停止計時器
return () => clearInterval(intervalId);
}, [isRunning]);
const formatTime = (totalSeconds) => {
const mins = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
return (
<div style={{ textAlign: 'center', padding: '20px' }}>
<h1 style={{ fontSize: '4rem', fontFamily: 'monospace' }}>
{formatTime(seconds)}
</h1>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
<button onClick={() => setIsRunning(!isRunning)}>
{isRunning ? '暫停' : '開始'}
</button>
<button onClick={() => { setIsRunning(false); setSeconds(0); }}>
重置
</button>
</div>
</div>
);
}
場景 4:LocalStorage 同步
function PersistentCounter() {
const [count, setCount] = useState(() => {
const saved = localStorage.getItem('counter');
return saved ? JSON.parse(saved) : 0;
});
useEffect(() => {
localStorage.setItem('counter', JSON.stringify(count));
}, [count]);
return (
<div>
<p>Count: {count}(會保存到 localStorage)</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(0)}>重置</button>
</div>
);
}
場景 5:API 資料獲取
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isCancelled = false; // 防止在元件卸載後設定 state
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
if (!response.ok) throw new Error('載入失敗');
const data = await response.json();
if (!isCancelled) {
setUser(data);
}
} catch (err) {
if (!isCancelled) {
setError(err.message);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
fetchUser();
return () => {
isCancelled = true; // 元件卸載時取消
};
}, [userId]);
if (loading) return <p>載入中...</p>;
if (error) return <p style={{ color: 'red' }}>錯誤:{error}</p>;
if (!user) return <p>找不到使用者</p>;
return (
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>電話: {user.phone}</p>
</div>
);
}
場景 6:防抖 (Debounce) 搜尋
function DebouncedSearch() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [results, setResults] = useState([]);
// 防抖:使用者停止輸入 500ms 後才觸發搜尋
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
}, 500);
return () => clearTimeout(timer);
}, [query]);
// 搜尋
useEffect(() => {
if (!debouncedQuery) {
setResults([]);
return;
}
const allItems = ['React', 'Redux', 'Router', 'Vue', 'Angular', 'Svelte'];
const filtered = allItems.filter((item) =>
item.toLowerCase().includes(debouncedQuery.toLowerCase())
);
setResults(filtered);
}, [debouncedQuery]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜尋..."
/>
<p style={{ color: '#999', fontSize: '0.85rem' }}>
搜尋:{debouncedQuery || '(等待輸入...)'}
</p>
<ul>
{results.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
10.5 useEffect 常見錯誤
錯誤 1:缺少依賴
// ❌ ESLint 會警告缺少依賴
useEffect(() => {
document.title = `Count: ${count}`;
}, []); // count 沒有在依賴陣列中
// ✅ 正確
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
錯誤 2:無限迴圈
// ❌ 無限迴圈!每次渲染都更新 state → 觸發渲染 → 再觸發 effect
useEffect(() => {
setCount(count + 1); // 會觸發重新渲染
}); // 沒有依賴陣列 = 每次渲染都執行
// ❌ 無限迴圈!物件每次都是新的參考
const [data, setData] = useState({});
useEffect(() => {
setData({ ...data, updated: true }); // 新物件 → 觸發渲染 → 再次觸發
}, [data]); // data 是依賴,但每次都建立新物件
錯誤 3:忘記清除
// ❌ 記憶體洩漏:沒有清除 interval
useEffect(() => {
setInterval(() => {
console.log('tick');
}, 1000);
}, []);
// ✅ 清除 interval
useEffect(() => {
const id = setInterval(() => console.log('tick'), 1000);
return () => clearInterval(id);
}, []);
10.6 完整範例:即時天氣儀表板
import { useState, useEffect } from 'react';
function WeatherDashboard() {
const [city, setCity] = useState('Taipei');
const [weather, setWeather] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [lastUpdated, setLastUpdated] = useState(null);
const [autoRefresh, setAutoRefresh] = useState(false);
const cities = ['Taipei', 'Tokyo', 'Seoul', 'New York', 'London'];
// 模擬天氣 API(實際開發中替換為真實 API)
const fetchWeather = async (cityName) => {
setLoading(true);
setError(null);
try {
await new Promise((resolve) => setTimeout(resolve, 800));
const mockWeather = {
Taipei: { temp: 28, humidity: 75, condition: '☀️ 晴天', wind: 12 },
Tokyo: { temp: 22, humidity: 60, condition: '⛅ 多雲', wind: 18 },
Seoul: { temp: 19, humidity: 55, condition: '🌧️ 雨天', wind: 25 },
'New York': { temp: 15, humidity: 40, condition: '🌤️ 晴時多雲', wind: 30 },
London: { temp: 12, humidity: 80, condition: '🌫️ 霧', wind: 15 },
};
const data = mockWeather[cityName];
if (!data) throw new Error(`找不到 ${cityName} 的天氣資料`);
const noise = Math.floor(Math.random() * 5) - 2;
setWeather({ ...data, temp: data.temp + noise });
setLastUpdated(new Date().toLocaleTimeString('zh-TW'));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// 城市變更時取得天氣
useEffect(() => {
fetchWeather(city);
}, [city]);
// 自動更新
useEffect(() => {
if (!autoRefresh) return;
const intervalId = setInterval(() => {
fetchWeather(city);
}, 5000);
return () => clearInterval(intervalId);
}, [autoRefresh, city]);
// 更新頁面標題
useEffect(() => {
if (weather) {
document.title = `${city}: ${weather.temp}°C`;
}
return () => {
document.title = 'React App';
};
}, [city, weather]);
return (
<div style={{
maxWidth: '450px',
margin: '40px auto',
fontFamily: 'sans-serif',
padding: '24px',
borderRadius: '16px',
boxShadow: '0 4px 20px rgba(0,0,0,0.1)',
}}>
<h1 style={{ textAlign: 'center', marginBottom: '24px' }}>天氣儀表板</h1>
{/* 城市選擇 */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px', flexWrap: 'wrap' }}>
{cities.map((c) => (
<button
key={c}
onClick={() => setCity(c)}
style={{
padding: '6px 14px',
borderRadius: '20px',
border: 'none',
backgroundColor: city === c ? '#4299e1' : '#edf2f7',
color: city === c ? 'white' : '#4a5568',
cursor: 'pointer',
fontWeight: city === c ? 'bold' : 'normal',
}}
>
{c}
</button>
))}
</div>
{/* 天氣顯示 */}
{loading && <p style={{ textAlign: 'center' }}>載入中...</p>}
{error && (
<p style={{ color: '#e53e3e', textAlign: 'center' }}>
錯誤:{error}
</p>
)}
{weather && !loading && (
<div style={{
textAlign: 'center',
padding: '24px',
backgroundColor: '#f7fafc',
borderRadius: '12px',
}}>
<div style={{ fontSize: '4rem' }}>{weather.condition.split(' ')[0]}</div>
<h2 style={{ margin: '8px 0' }}>{city}</h2>
<p style={{ fontSize: '3rem', fontWeight: 'bold', margin: '8px 0' }}>
{weather.temp}°C
</p>
<div style={{ display: 'flex', justifyContent: 'center', gap: '24px', color: '#718096' }}>
<span>💧 {weather.humidity}%</span>
<span>💨 {weather.wind} km/h</span>
</div>
</div>
)}
{/* 控制列 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '16px',
fontSize: '0.85rem',
color: '#718096',
}}>
<span>最後更新:{lastUpdated || '-'}</span>
<label style={{ cursor: 'pointer' }}>
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
自動更新(每 5 秒)
</label>
</div>
</div>
);
}
export default WeatherDashboard;
10.7 練習題
練習 1:建立一個倒數計時器
- 可設定秒數
- 開始/暫停/重置功能
- 倒數結束時發出提示
練習 2:建立一個即時時鐘
- 顯示目前時間(每秒更新)
- 可切換 12/24 小時制
- 可選擇不同時區
本課重點回顧
useEffect用來處理副作用- 空陣列
[]:只執行一次(掛載時) - 有依賴:依賴改變時才執行
- 清除函式:避免記憶體洩漏(事件監聽、計時器、訂閱)
- 非同步操作要注意取消機制(
isCancelledflag) - 不要在 effect 中造成無限迴圈