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 小時制
  • 可選擇不同時區

本課重點回顧

  1. useEffect 用來處理副作用
  2. 空陣列 []:只執行一次(掛載時)
  3. 有依賴:依賴改變時才執行
  4. 清除函式:避免記憶體洩漏(事件監聽、計時器、訂閱)
  5. 非同步操作要注意取消機制isCancelled flag)
  6. 不要在 effect 中造成無限迴圈