Common Mistakes with useEffect Dependencies

React
#Notes #React

useEffect 作為 cleanup function,是個強大的副作用處理函式,但如果加了錯誤的依賴,可能導致效能變差,或發生無法預期的行為。此外也談談一些對 useEffect 的誤解。

重新認識 useEffect

首先,React 的核心概念是透過資料驅動畫面,而最常見的做法就是使用 useState 來管理元件的狀態。當狀態改變時,React 會自動重新渲染元件,讓畫面與資料保持同步。

但有時候,我們需要處理的效果不僅僅是畫面的更新,比如與瀏覽器 API 的互動、資料的請求或手動管理某些資源,這些操作通常稱為「副作用(side effects)」,它們與元件的渲染過程並不直接相關,但卻是必須進行的。

這時候就需要 useEffect 來幫我們在 React 的渲染週期之外執行副作用,另外 useEffect 也提供了 Dependencies Array,確保副作用僅在必要的時候去執行。

但儘管如此,在還不了解他底層原理的情況下還是經常被誤用,過去在學習也只是知道他會在元件初始化與卸載被觸發,可以透過加上 Dependencies Array 確保只在需要的時候被執行,所以這篇文章要來考古他是怎麼被設計的。

補充:副作用(Effect)是什麼?副作用指的是那些改變了程式外部狀態或環境的操作,而這些操作並不是由函式的輸入(例如參數)直接引起的。例如:Web API(setTimeout、localStorage)、DOM 操作、Data Fetching、Event Subscription。

React Class Component

在過去還是 Class Component 的年代,設計和運作方式很多時候都是不直觀的,像是 React 生命週期的方法就有三種來處理不同的渲染時機的邏輯:

  • componentDidMount:元件初次渲染後執行的方法。
  • componentDidUpdate:元件更新(重新渲染)後執行的方法。
  • componentWillUnmount:元件卸載前執行的方法,用於清理資源。

image

這些方法將元件的渲染時機劃分得非常明確,開發者需要明確地在這些方法中撰寫相關邏輯。然而,這種方式有幾個缺點:

  1. 每個時機點的邏輯分散在不同的方法中,容易導致程式碼攏長、不直觀。
  2. 如果多個生命周期方法中有相似的邏輯,容易造成程式碼重複性高且難以維護。

這對於初學者來說,是一個學習曲線比較高的地方,特別是當僅僅需要一個「純渲染狀態的元件」(stateless component)時,使用 Class Component 就顯得不必要地複雜,且通常也需要撰寫更多的樣板程式碼(boilerplate)。

所以後來就有了 Function Component 和 Hook,它們的存在就是為了解決這些問題,讓邏輯更集中,程式碼更簡潔。

回到現在的 useEffect,它是 React 引入 Function Component 後的一個關鍵功能,旨在解決過去 Class Component 中生命週期方法的局限性,不需要分別撰寫 componentDidMountcomponentDidUpdatecomponentWillUnmountuseEffect 可以在一個地方同時處理初始化、更新以及清理的邏輯。

補充:useEffect 非生命週期方法,而是類似生命週期功能,與傳統生命週期方法的設計理念不同,React 的核心是聲明式(declarative)的渲染,而 useEffect 的設計是為了讓副作用在聲明式的框架下運行,與命令式(imperative)的生命週期方法不同。

Dependencies 是一種效能最佳化,而非邏輯控制

過去在使用 useEffect 腦中的想法都是,「我希望這段程式在特定時候被執行」,像是:我希望在元件載入時執行,或某個資料狀態變更時執行,但這種想法其實不太對。

為什麼會需要 Dependencies?

React 的基本概念是「資料驅動畫面」,當元件的狀態或 props 改變時,React 會觸發 re-render,在這個過程中,所有的副作用預設情況下都會被重新執行,對於不需要重新執行的副作用而言,這種行為會導致效能浪費。

為了解決這個問題,React 提供了 Dependencies Array,讓我們可以告訴 React,只有在某些特定資料變更時才重新執行 useEffect。這樣就能避免在不需要的時候重複執行副作用,從而提高效能。

正確的效能優化:使用 Dependencies

假設我們有一個元件會根據 searchTerm 的改變來觸發搜尋 API,但我們也同時有一個無關的 count state。

import { useState, useEffect } from "react";

function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState("");
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`Searching for: ${searchTerm}`);
    // 模擬 API 請求
  }, [searchTerm]); // 依賴 searchTerm

  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search here..."
      />
      <button onClick={() => setCount(count + 1)}>Click Me: {count}</button>
    </div>
  );
}

這段程式碼會在每次 searchTerm 改變時,執行 useEffect,因為 Dependencies Array 中包含了 searchTerm

但如果我們點擊按鈕增加 count,這會觸發元件的 re-render,但 useEffect 不會執行,因為 searchTerm 沒有改變。

而錯誤的用法:試圖用 Dependencies 控制執行時機

如果我們把非依賴的 count 放進 Dependencies Array:

useEffect(() => {
  console.log(`Searching for: ${searchTerm}`);
}, [searchTerm, count]); // count 不應該是依賴

這樣每次按下按鈕,useEffect 都會執行,即使 searchTerm 沒有改變。這樣的做法會讓 React 無法正確判斷是否需要重新執行副作用。

因此,「Dependencies 是為了讓 React 知道在資料沒有發生改變時,可以安全地跳過執行,而不是用來控制 effect 什麼時候執行。」

所以「你應該對 Dependencies 誠實」,欺騙 Dependencies 將無關的資料加進 Dependencies Array,會導致不必要的渲染和副作用執行,並可能引發難以追蹤的錯誤。

避免傳入參考

在 JavaScript 中,物件和陣列都是參考型別(reference type),這表示當你傳遞物件或陣列時,傳遞的是對該物件或陣列的「參考」,而非真正的值。

這可能會導致每當物件或陣列的內容發生變化時,即使資料本身沒有改變(例如內部的某個屬性或元素變動),也會觸發 useEffect 重新執行,糟糕的情況可能導致畫面一直重複 re-render。

function Example() {
  const [user, setUser] = useState({ name: "Alice", age: 30 });

  useEffect(() => {
    console.log(`User changed: ${user.name}, ${user.age}`);
  }, [user]); // user 是參考型別

  const updateUser = () => {
    setUser({ name: "Bob", age: 25 }); // 創建一個新的物件
  };

  return (
    <div>
      <p>{user.name}</p>
      <button onClick={updateUser}>Update User</button>
    </div>
  );
}

每次我們調用 setUser 更新 user 時,React 會認為 user 變更了,並執行 useEffect。即使 nameage 的值沒有實際變化,因為 setUser 實際上是傳入一個新的物件,這會導致 useEffect 被不必要地重新執行。

解決方法

為了避免這種情況,我們應該盡量使用「簡單型別」作為依賴項目,如 string、number、boolean 等,這些型別的變動能夠更精確地反映資料的變化,並且不會受到參考型別的影響。

如果需要在 useEffect 中處理物件或陣列,則應該使用「物件深比較」或「記錄物件狀態的關鍵屬性」來作為依賴,像是:user.name。或者是可以使用 useMemouseCallback 進一步優化。

參考來源