Blog

/

Frontend

Next.js - useEffect

useEffect dependency array WebSocket useEffect useState cleanupdependency array 90% bug

Posted at

2024年10月25日

Posted on

Frontend

useEffect 是用來執行副作用的 Hook。副作用指的是那些不單純只是計算的邏輯,比如 fetch 資料、訂閱事件、改變 DOM、儲存到 localStorage。在 React 的函數型組件裡,這些邏輯都放在 useEffect 裡。

useEffect(() => {
  // 副作用邏輯
}, [依賴])

useEffect 接收兩個參數。第一個是一個函數,裡面是你要執行的邏輯。第二個是依賴陣列,用來控制什麼時候執行。

什麼時候用 useEffect?當你需要在組件初始化、或某些值變化時,執行一些邏輯。比如頁面載入時 fetch 資料、輸入框內容變化時驗證、組件卸載時清理計時器。如果沒有先理解 useEffect 的執行時機和 dependency array 的規則,有可能會寫出無限迴圈、資料不同步、重複訂閱的髒 code。

特性

說明

何時出現

初始化執行

組件首次渲染後執行

空 dependency array

依賴變化執行

依賴改變時執行

有 dependency 值變化

cleanup 函數

下一個 effect 前或卸載時執行

return 一個函數

無限迴圈

effect 更新依賴,導致不停執行

缺少 dependency array 或用錯依賴

多個 useEffect

按定義順序執行

同一個組件多個 useEffect

useEffect 的執行順序

要理解 useEffect,必須先搞清楚它什麼時候執行。假設你寫一個組件,需要在初始化時 fetch 資料:

'use client'

import { useEffect, useState } from 'react'

export function DataFetcher() {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    console.log('Effect 執行')
    
    fetch('/api/data')
      .then(r => r.json())
      .then(result => {
        setData(result)
        setLoading(false)
      })
  }, []) // 空 dependency,只執行一次
}

組件在首次渲染後,useEffect 才會去執行,順序是這樣的:

第一步:組件初始化,useState 設定 data = null,loading = true。

第二步:組件渲染到頁面。

第三步:useEffect 執行,開始 fetch。

第四步:fetch 完成後,setData 和 setLoading 觸發重新渲染。

第五步:組件重新渲染,顯示新資料。

為什麼不在組件函數頂層直接 fetch?因為函數型組件可能會重新執行多次,你不希望每次都發起一個新的 fetch。useEffect 讓你控制什麼時候執行一次邏輯。

如果是依賴變化的情況呢?假設你的頁面支援搜尋,搜尋詞變化時要重新 fetch:

'use client'

import { useEffect, useState } from 'react'

export function SearchData() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  useEffect(() => {
    console.log('Effect 執行,query =', query)
    
    if (!query) {
      setResults([])
      return
    }

    fetch(`/api/search?q=${query}`)
      .then(r => r.json())
      .then(setResults)
  }, [query]) // query 變化時執行
}

這次 dependency array 不是空,而是 [query]。意思是當 query 變化時,useEffect 就執行一次。用戶輸入「apple」時,effect 執行一次。改成「apricot」時,又執行一次。

第一步:組件初始化,query = '',effect 執行但因為 query 是空就直接 return。

第二步:用戶輸入「apple」,query 變成 'apple'。

第三步:因為 query 在 dependency 裡,effect 執行,fetch 'apple' 的結果。

第四步:結果返回,setResults 觸發重新渲染,這樣就實現了搜尋功能。

Cleanup 函數的用途

假設你要建立一個 WebSocket 連接,監聽即時資料。當組件卸載時,你要關閉連接,否則會洩露記憶體,這時候你就可以用 Cleanup 函數:

'use client'

import { useEffect, useState } from 'react'

export function RealtimeData() {
  const [data, setData] = useState(null)

  useEffect(() => {
    console.log('建立連接')
    
    const ws = new WebSocket('ws://localhost:8000/data')
    
    ws.onmessage = (event) => {
      const newData = JSON.parse(event.data)
      setData(newData)
    }

    return () => {
      console.log('關閉連接')
      ws.close()    // 這就是 cleanup
    }
  }, [])
}

原本組件初始化,執行 useEffect 後建立 WebSocket 連接。然後當用戶離開頁面或組件卸載,cleanup 函數執行就會關閉連接。

為什麼需要 cleanup?因為如果不關閉連接,WebSocket 在 background 繼續跑就會浪費記憶體和頻寬,這時候 cleanup 就能釋放用不到的資源。

cleanup 函數也可以根據依賴變化時執行。假設你監聽的是某個用戶的資料,userId 變化時要換個連接:

useEffect(() => {
  console.log('監聽用戶', userId)
  
  const ws = new WebSocket(`ws://localhost:8000/user/${userId}`)
  
  ws.onmessage = (event) => {
    setData(JSON.parse(event.data))
  }

  return () => {
    console.log('停止監聽用戶', userId)
    ws.close()
  }
}, [userId]) // userId 變化時執行

第一步:userId = 1 時,effect 執行,建立連接。

第二步:userId 變成 2,cleanup 執行(關閉舊連接),然後新的 effect 執行(建立新連接)。

第三步:組件卸載,cleanup 執行(關閉連接)。

Dependency Array

dependency array 有許多容易犯的小錯(其實是我犯過的乾),首先像是做一做忘記加 dependency。假設你要監聽 userId 的變化,但寫成了空陣列:

const [userId, setUserId] = useState(1)

useEffect(() => {
  fetch(`/api/user/${userId}`)
    .then(r => r.json())
    .then(setUserData)
}, []) // 缺少 userId

userId 變了,effect 不會重新執行,用戶換帳號,頁面還顯示舊帳號的資料。

正確的做法是把 userId;當加進 dependency userId 變化時,effect 會重新執行拿到新資料。

useEffect(() => {
  fetch(`/api/user/${userId}`)
    .then(r => r.json())
    .then(setUserData)
}, [userId]) // 加上 userId

也有人會不小心把物件或陣列作為依賴,假設你有一個配置物件:

const config = { userId: 1, theme: 'dark' }

useEffect(() => {
  fetch(`/api/data?userId=${config.userId}`)
    .then(setData)
}, [config]) // 用物件做依賴

這樣寫的話每次組件重新渲染,config 都是新物件(即使內容一樣)。React 比較的是物件的引用,不是內容。所以 config 被認為「變化」了,effect 會不停執行。

有兩個辦法,一是從物件中提取你真正用到的欄位:

useEffect(() => {
  fetch(`/api/data?userId=${config.userId}`)
    .then(setData)
}, [config.userId]) // 用原始值做依賴

二是確保 config 不會重複建立,把它定義在組件外,或用 useMemo 記憶化。

另一個常見錯誤是沒有 dependency array,如果你根本不寫 dependency array:

useEffect(() => {
  console.log('執行')
  setData(someValue)
}) // 沒有第二個參數

這樣 effect 會在每次渲染後都執行,會導致無限迴圈。

無限迴圈的常見原因

無限迴圈是 useEffect 最常見的 bug。組件不停重新渲染,effect 不停執行,導致頁面卡住。最常見的原因是 effect 更新了 dependency 裡的值。比如:

const [count, setCount] = useState(0)

useEffect(() => {
  setCount(count + 1) // 導致無限迴圈
}, [count]) // count 變 -> effect 執行 -> setCount -> count 變 -> effect 執行

這就是無限迴圈,每次 effect 執行,都會更新 count,count 變化導致 effect 再執行。

解決辦法取決於你想做什麼。如果你只想初始化一次做點事,用空 dependency:

useEffect(() => {
  // 初始化邏輯
}, [])

如果你確實需要監聽某個值,但不能在 effect 裡改它,那就不改。或者用 useRef 保存值,useRef 變化不會觸發 effect。

另一個常見原因是物件或函數在 dependency 裡。假設你傳一個物件進去:

const config = { userId: 1 }

useEffect(() => {
  fetch(`/api/data?userId=${config.userId}`)
}, [config]) // config 每次都是新對象,導致無限迴圈

這也是無限迴圈,因為 config 每次都是新物件,我們可以用 useMemo 解決:

const config = useMemo(() => ({ userId: 1 }), [])

useEffect(() => {
  fetch(`/api/data?userId=${config.userId}`)
}, [config])

或者直接用值:

useEffect(() => {
  fetch(`/api/data?userId=${1}`)
}, []) // dependency 是空,不會無限迴圈

useEffect 和 Server Components 的協調

之前有講過 Server Components 和 Client Components 有不同的執行環境。useEffect 只能在 Client Component 用,因為它需要瀏覽器環境。

// app/products/[id]/page.: (Server Component)
import { db } from '@/lib/database'
import { ProductDetail } from './ProductDetail'

export default async function ProductPage({ params }) {
  // Server Component 初始化資料
  const product = await db.products.findById(params.id)
  
  return <ProductDetail product={product} />
}
// app/products/[id]/ProductDetail.tsx (Client Component)
'use client'

import { useState, useEffect } from 'react'

export function ProductDetail({ product }) {
  const [liked, setLiked] = useState(false)

  useEffect(() => {
    // 監聽用戶的點讚狀態
    const saved = localStorage.getItem(`liked-${product.id}`)
    if (saved) setLiked(true)
  }, [product.id])

  const handleLike = () => {
    setLiked(!liked)
    localStorage.setItem(`liked-${product.id}`, 'true')
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <button onClick={handleLike}>
        {liked ? '已讚' : '讚'}
      </button>
    </div>
  )
}

Server Component 拿資料很快(0.5 秒),而 Client Component 可以用 useEffect 監聽用戶互動。

多個 useEffect 的執行順序

一個組件可以有多個 useEffect。它們會按定義順序執行。假設你寫了三個:

'use client'

import { useEffect } from 'react'

export function MultiEffect() {
  useEffect(() => {
    console.log('Effect 1')
  }, [])

  useEffect(() => {
    console.log('Effect 2')
  }, [])

  useEffect(() => {
    console.log('Effect 3')
  }, [])
}

輸出會是:

Effect 1
Effect 2
Effect 3

但如果是要 cleanup 時反序執行:

Cleanup 3
Cleanup 2
Cleanup 1

為什麼順序是反的?因為第 3 個 effect 可能依賴前面的,清理時要先清理後面的,再清理前面的。

小結

使用 useEffect 時,如果要初始化可以用用空 array,而監聽某個值用 [value]。再來如果要考慮 cleanup 的問題時,fetch 不用,WebSocket 要用。最吼也要注意 dependency 對不對,用到的所有外部值都要加進去。另外 eslint-plugin-react-hooks 是一個不錯的外掛,可以協助判斷錯誤 :)

下次講講 useCallback 和 useMemo,會涉及性能優化的問題

More Blog