React useEffect

useEffect 是 React 用來處理「副作用」(side effects)的 Hook。副作用是指那些會影響元件外部或無法在渲染過程中完成的操作,例如:

  • 資料獲取(API 呼叫)
  • 訂閱(WebSocket、事件監聽)
  • 手動操作 DOM
  • 設定計時器
  • 讀寫 localStorage

基本語法

import { useEffect } from 'react'

useEffect(() => {
  // 副作用程式碼
}, [dependencies])
  • 第一個參數:一個函式,包含要執行的副作用程式碼
  • 第二個參數:依賴陣列(dependency array),決定何時重新執行副作用

依賴陣列的三種情況

1. 沒有依賴陣列 - 每次渲染都執行

useEffect(() => {
  console.log('每次渲染後都會執行')
})
這種寫法通常是錯誤的,因為它會導致無限迴圈或效能問題。

2. 空陣列 [] - 只在首次渲染後執行

useEffect(() => {
  console.log('只在元件掛載時執行一次')
}, [])

這相當於以前 Class Component 的 componentDidMount

3. 有依賴項目 - 依賴變動時執行

useEffect(() => {
  console.log(`count 變成了 ${count}`)
}, [count])

count 的值改變時,這個 effect 會重新執行。

實際範例

範例 1:資料獲取

import { useState, useEffect } from 'react'

function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    // 重置狀態
    setLoading(true)
    setError(null)

    // 獲取使用者資料
    fetch(`https://api.example.com/users/${userId}`)
      .then((response) => {
        if (!response.ok) {
          throw new Error('無法載入使用者資料')
        }
        return response.json()
      })
      .then((data) => {
        setUser(data)
        setLoading(false)
      })
      .catch((err) => {
        setError(err.message)
        setLoading(false)
      })
  }, [userId]) // 當 userId 改變時重新獲取

  if (loading) return <p>載入中...</p>
  if (error) return <p>錯誤:{error}</p>
  if (!user) return null

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

範例 2:更新文件標題

import { useState, useEffect } from 'react'

function Counter() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    // 更新瀏覽器標題
    document.title = `你點擊了 ${count} 次`
  }, [count])

  return (
    <div>
      <p>你點擊了 {count} 次</p>
      <button onClick={() => setCount(count + 1)}>點我</button>
    </div>
  )
}

清理函式(Cleanup)

有些副作用需要「清理」,例如取消訂閱、清除計時器、移除事件監聽。你可以在 effect 函式中返回一個清理函式:

useEffect(() => {
  // 設定副作用

  return () => {
    // 清理副作用
  }
}, [dependencies])

清理函式會在:

  • 元件卸載(unmount)時執行
  • 下一次 effect 執行之前執行(當依賴改變時)

範例:計時器

import { useState, useEffect } from 'react'

function Timer() {
  const [seconds, setSeconds] = useState(0)

  useEffect(() => {
    // 設定計時器
    const intervalId = setInterval(() => {
      setSeconds((prev) => prev + 1)
    }, 1000)

    // 清理:清除計時器
    return () => {
      clearInterval(intervalId)
    }
  }, []) // 空陣列:只在掛載時設定,卸載時清理

  return <p>已經過 {seconds} 秒</p>
}

範例:事件監聽

import { useState, useEffect } from 'react'

function WindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  })

  useEffect(() => {
    function handleResize() {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      })
    }

    // 新增事件監聽
    window.addEventListener('resize', handleResize)

    // 清理:移除事件監聽
    return () => {
      window.removeEventListener('resize', handleResize)
    }
  }, [])

  return (
    <p>
      視窗大小:{size.width} x {size.height}
    </p>
  )
}

常見錯誤與解決方案

錯誤 1:忘記清理訂閱

// ❌ 錯誤:沒有清理事件監聽
useEffect(() => {
  window.addEventListener('scroll', handleScroll)
}, [])

// ✅ 正確:記得清理
useEffect(() => {
  window.addEventListener('scroll', handleScroll)
  return () => window.removeEventListener('scroll', handleScroll)
}, [])

錯誤 2:依賴陣列遺漏依賴

// ❌ 錯誤:effect 使用了 userId 但沒有放在依賴中
useEffect(() => {
  fetchUser(userId)
}, []) // userId 改變時不會重新獲取

// ✅ 正確:將所有使用到的值放入依賴
useEffect(() => {
  fetchUser(userId)
}, [userId])

錯誤 3:物件/陣列作為依賴

// ❌ 問題:每次渲染 options 都是新物件,導致 effect 不斷執行
function Component({ id }) {
  const options = { id, verbose: true }

  useEffect(() => {
    fetchData(options)
  }, [options]) // 每次都是新物件!
}

// ✅ 解法 1:將物件移到 effect 內部
useEffect(() => {
  const options = { id, verbose: true }
  fetchData(options)
}, [id])

// ✅ 解法 2:使用 useMemo
const options = useMemo(() => ({ id, verbose: true }), [id])
useEffect(() => {
  fetchData(options)
}, [options])

處理非同步操作

useEffect 的函式不能是 async,但你可以在內部定義 async 函式:

useEffect(() => {
  // ❌ 錯誤:useEffect 不能直接用 async
  // async () => { ... }

  // ✅ 正確:在內部定義 async 函式
  async function fetchData() {
    try {
      const response = await fetch('/api/data')
      const data = await response.json()
      setData(data)
    } catch (error) {
      setError(error)
    }
  }

  fetchData()
}, [])

處理競態條件(Race Condition)

當快速連續請求時,可能會出現舊的請求覆蓋新的結果:

useEffect(() => {
  let cancelled = false

  async function fetchData() {
    const response = await fetch(`/api/users/${userId}`)
    const data = await response.json()

    // 只有在沒有被取消時才更新狀態
    if (!cancelled) {
      setUser(data)
    }
  }

  fetchData()

  // 清理:標記這次請求已被取消
  return () => {
    cancelled = true
  }
}, [userId])

useEffect vs 事件處理

不是所有的邏輯都應該放在 useEffect 中。一個簡單的判斷原則:

  • 事件處理:由使用者操作觸發的動作(點擊、輸入、提交)
  • useEffect:需要與外部系統同步的操作
function SearchPage() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  // ✅ 事件處理:使用者提交搜尋
  function handleSubmit(e) {
    e.preventDefault()
    search(query)
  }

  // ✅ useEffect:頁面載入時獲取熱門搜尋
  useEffect(() => {
    fetchPopularSearches().then(setResults)
  }, [])

  return (
    <form onSubmit={handleSubmit}>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <button type="submit">搜尋</button>
    </form>
  )
}