[React] 對 hooks 的 dependencies 誠實,以維護資料流的連動

Monica
23 min readJun 12, 2024

--

《React 思維進化》 筆記系列
1. [React] DOM, Virtual DOM 與 React element
2. [React] 了解 JSX 與其語法、畫面渲染技巧
3. [React] 單向資料流介紹以及 component 初探
4. [React] 認識狀態管理機制 state 與畫面更新機制 reconciliation
5. [React] 在子 component 觸發父 component 資料更新、深入理解 batch update 與 updater function
6. [React] 了解 immutable state 與 immutable update 方法
7. [React] 認識 component 的生命週期、了解每次 render 都有自己版本的資料
8. [React] React 中的副作用處理、初探 useEffect
9. [React] 對 hooks 的 dependencies 誠實,以維護資料流的連動
10. [React] React 18 effect 函式執行兩次的原因及 useEffect 常見情境
11. [React] 認識 useCallback、useMemo,了解 hooks 運作原理

前言

承接上篇 [React] React 中的副作用處理、初探 useEffect,此篇主要敘述的是《React 思維進化》 5–3 章節的筆記,若有錯誤歡迎大家回覆告訴我~

欺騙 dependencies 會有什麼問題?

上篇已大力強調要對 dependencies 誠實,這裡我們要深入探討如果不誠實,會有什麼問題,以以下範例來說,預期每經過一秒畫面上數字自動 +1,但實際上數字只增加一次就不動了。

import { useState, useEffect } from 'react';

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

useEffect(
() => {
const id = setInterval(
() => {
setCount(count + 1);
},
1000
);

return () => clearInterval(id);
},
[]
);

return <h1>{count}</h1>;
}

為何如此?因爲 effect 函式有依賴 count 資料,我們卻以 [] 作為 dependencies 參數、欺騙了 React。

嘗試模擬 render 流程:

//第一次 render 時,count state 值為 0

function Counter() {
const count = 0;//從 useState 取出的值,會作為該次 render 永不變的常數

useEffect(
() => {
const id = setInterval(
() => {
//count 值在這次 render 固定是 0,因此 setCount(count + 1) 其實是 setCount(0 + 1),也就是 setCount(1)
setCount(count + 1);
},
1000
);

return () => clearInterval(id);
},
[]
);

//...
}
//第二次 render 時,count state 值為 1

function Counter() {
const count = 1;

useEffect(
//第二次 render 時,此 effect 函式有被產生
() => {
const id = setInterval(
() => {
setCount(count + 1); //此時 count 是 1,也就是 setCount(2)
},
1000
);

return () => clearInterval(id);
},
[] //因為 dependencies 是空陣列,React 會跳過執行此 effect 函式
);

//...
}

每次 render 都還是會再次產生 effect 函式,但因為 dependencies 是空陣列,第一次 render 後的每一次 render 都會被 React 判斷可跳過執行 effect 函式,因此從頭到尾只會執行第一次 render setInterval 中的 setCount(1)

那要如何解決?就是誠實填寫 dependencies ,在 effect 函式用到 count 值,就該把 count 納入 dependencies。

useEffect(
() => {
const id = setInterval(
() => {
setCount(count + 1);
},
1000
);

return () => clearInterval(id);
},
[count] //effect 函式依賴 count 資料,dependencies 要誠實填寫 effect 函式依賴的資料
);

誠實填寫 dependencies 的 render 流程如下:

此時畫面如預期正常運作,但有個問題,當 setCount 被呼叫而觸發 re-render 時,舊的 setInterval 就會被清掉,接著重新設定新的,但理想上我們希望只要設定一次 setInterval 即可。

那要如何只設定一次 setInterval,又不欺騙 dependencies?

讓 effect 函式對依賴資料自給自足

無論如何,都應對 dependencies 誠實,因此若要解決上述問題,要調整 effect 函式邏輯,讓它不再依賴 count 變數。我們可用 updater function 來呼叫 setCount ,做到「依據既有 state 值延伸計算並更新 state」。

useEffect(
() => {
const id = setInterval(
() => {
setCount(prevCount => prevCount + 1);//以 updater function 計算新值,不依賴 count 變數
},
1000
);

return () => clearInterval(id);
},
[] //effect 函式不依賴 count 變數,count 可從 dependencies 移除
);

當 effect 函式不依賴 count 變數,就可將 count 從 dependencies 移除,且第一次 render 執行 setInterval 副作用後,之後 render 都可安全跳過副作用,畫面上 count逐次增加的邏輯仍可正常執行。

將資料的值與操作解耦

updater function 讓我們可以只告訴 React 「我想讓 state 值以目前的值增加 1」,而不提及資料目前的值,因為 React 知道目前值多少,它可以按照我們期待的操作流程去完成操作。這種只描述「操作資料的流程」而不提及資料的值,可將「資料本身的值」與「操作資料的流程」解耦。

updater function 可讓我們安全移除 effect 函式對 state 值的依賴,又可維持對 dependencies 的誠實。

補充:在程式設計,「解耦(decoupling)」指降低不同元件、模組或處理流程間的依賴性

函式型別的依賴

若副作用處理用到了函式,因為要對 dependencies 誠實,所以我們將函式放在 dependencies 參數內:

import { useState, useEffect } from 'react';

export default function SearchResults(){
const [query, setQuery] = useState('hello');

async function fetchData(){
const result = await fetch(
`https://bar.com/api/search?query=${query}`
);
}

useEffect(()=>{
fetchData();
},[fetchData]);//對 dependencies 誠實,但效能優化其實永遠都會失效,因為 fetchData 每次 render 都會是新的函式

//...
}

如此一來,可讓「query 變數連動 API 請求的副作用處理」正常運作,當 query 值不同時,fetchData 會依據 query 的值不同,而呼叫不同的 API。然而,這產生另一個問題:dependencies 的效能優化會永遠失敗,effect 函式在每次 render 後都會執行。

為何 dependencies 效能優化會失效?

fetchData 被宣告在 component function 內,每次 render 都會重新產生,dependencies 比較時,就會判定每次依賴都有更新,因此每次都會照常執行副作用,不論 query 值有沒有變。

你可能想說,沒關係,反正邏輯還是有照我預期的執行,「query 變數連動 API 請求的副作用處理」有正常運作就好,但要注意的是,⛔️ 效能優化失敗,比沒有提供 dependencies 參數還糟糕,因比較 dependencies 需要效能成本,沒有 dependencies 就固定每次都執行就好。

那麼,當 effect 函式依賴 component function 內的另一個函式,如何保有效能優化效果、同時對 dependencies 誠實?

✅ 把函式定義移到 effect 函式內

若只在 effect 函式用到自定義函式,可將函式直接寫進 effect 函式內:

import { useState, useEffect } from 'react';

export default function SearchResults(){
const [query, setQuery] = useState('hello');

useEffect(()=>{
//將 fetchData 函式定義放到 effect 函式內,fetchData 函式只有在 effect 函式被執行時才會重新產生
async function fetchData(){
const result = await fetch(
`https://bar.com/api/search?query=${query}`
);
}
fetchData();
},[query]);//對 dependencies 誠實

//...其他地方不需要用到 fetchData 函式
}

fetchData 函式搬到 effect 函式內部,fetchData 不再是 effect 函式的依賴資料,此時 effect 函式會改依賴 query 變數。

補充:effect 函式(useEffect 第一個參數)不可以是 async function
如果要在副作用處理 promise,要先在 effect 函式內宣告一個 async function,在這個 async function 內再使用 await 語法。

但我不想將這函式放進 effect 函式內

如果這函式需要重用、如果我不想將函式放在 effect 函式內,該怎麼辦?

✅ 解法一:把與 component 資料流無關的流程抽到 component 外部

如果函式沒有依賴 component function 內的 props、state 或其他延伸資料,可將函式移到 component 外:

import { useState, useEffect } from 'react';

async function fetchData(query){
const result = await fetch(
`https://bar.com/api/search?query=${query}`
);
};

export default function SearchResults(){
const [query, setQuery] = useState('hello');

useEffect(()=>{
fetchData('react').then(result => { /*...*/ })
},[]); //dependencies 誠實,因 fetchData 定義在 component function 外部,是永不改變的函式

useEffect(()=>{
fetchData('javascript').then(result => { /*...*/ })
},[]); //dependencies 誠實,因 fetchData 定義在 component function 外部,是永不改變的函式

//...
}

不需將 fetchData 放入 dependencies,因 fetchData 不會隨 render 而重新產生。

關於 dependencies 內需放什麼值,上篇文章有大略提到,簡單來說,只有「在不同次 render 間有可能值會不同的資料」才要放入 dependencies,如:props、state 或相關衍生函式;而如果資料的值在不同次 render 間永遠相同,就不需要放入 dependencies。

✅ 解法二:把 useEffect 依賴的函式以 useCallback 包起來

應優先考慮解法一「將函式抽到 component 外」,但如果函式依賴許多 component 資料,將函式抽出會導致傳遞過多變數,再考慮此解法二。

在解法二,我們希望函式還是可以定義在 component 內,但這就會造成前面所說的效能優化失效的問題,這問題的本質是「資料→函式→副作用」的資料流被破壞,我們期待的資料流連動狀況如下:

期待的連動關係:若資料沒更新,連帶函式和副作用都不會更新(或被執行);若資料更新,才會連動函式和副作用被更新(或被執行)。

然而,當函式被定義在 component function 內,函式每次 render 都會被重新產生,就無法反映資料是否真的有更新,副作用因而無法判斷源頭資料是否有更新,而一律判定為有發生改變:

函式無法反映資料是否更新:不論資料是否更新,函式在每次 render 都重新產生並更新,導致副作用誤以為有更新而一律執行副作用處理

為了讓函式正確反映資料是否更新,我們可用 useCallback hook,useCallback 能將不同 render 間的資料變化正確的連動反應到函式,當資料在 render 間改變,依賴該資料的函式才跟著改變;當資料在 render 間不變,依賴該資料的函式也跟著不變,維持前次 render 的同函式。

useCallback 呼叫方式:

const cachedFn = useCallback(fn, dependencies);
  • 第一個參數 fn:傳入一個函式,通常是有依賴 component 內資料(如:props、state)的函式
  • 第二個參數 dependencies 陣列:概念與 useEffect 的 dependencies 類似,但 useCallback 的 dependencies 必填

useCallback 應用範例

如果在 component 內定義一個依賴 props.page 的函式,可用 useCallback 將函式包起來,並填寫依賴:

import { useCallback } from "react";

export default function SearchResult(props) {
const fetchData = useCallback(
async (query) => {
const result = await fetch(
`https://bar.com/api/search?query=${query}&page=${props.page}`
);
//...
},
[props.page] //dependencies 誠實,此函式依賴 props.page 資料;當 props.page 值與前一次 render 版本相同,就回傳前一次 render 產生的 fetchData 函式;當 props.page 值與前一次 render 版本不同,則回傳此次 render 產生的 fetchData 函式
);

//...
}

component 第一次 render 時,useCallback 將第一個參數中的函式直接回傳,並記住 dependencies 陣列。

component re-render 時,useCallback 將新 dependencies 陣列內的所有項目和上一次 render 的 dependencies 比較:

  • 如果全相同,忽略此次 render 傳入的新函式,useCallback 回傳前一次 render 的舊函式
  • 如果有任一不同,記住此次新傳入的函式和 dependencies 陣列,並回傳此次 render 的新函式

透過 useCallback ,可修補「資料→函式→副作用」資料流的漏洞,函式可正確感知資料變化,副作用也能正確感知源頭資料是否有變,並正確判斷是否能跳過此次副作用處理。

函式透過 useCallback 來感知資料是否更新,達到期待的資料流動關係

因此,我們能以 useCallback 處理函式,來解決 useEffect 中 dependencies 效能優化失效的問題:

將 fetchData 包在 useCallback 內,讓函式正確反應資料更新,參與資料流的依賴鏈

補充:useCallback 使用時機
不需預設將所有 component 內函式都以
useCallback 包起來,以下情境再使用:
- 當函式被用在 effect 函式中
- 當函式作為
props 傳給以 React.memo 包起來的 component 時

另外,React 19 RC 提出了 React Compiler,開發者可以不用手動加上 useCallback 來處理這些優化,而是交由 React Compiler 自動處理,不過 React Compiler 還在測試中,雖然未來有機會發布在正式版本,目前仍須了解 useCallback 的用途與應用時機~

以上 useCallback + useEffect 的範例顯示,函式在 function component 與 hooks 中是屬於資料流的一部分,藉由 useCallback,可讓函式參與進資料流,依賴資料流變化的機制也可正常運作,如:useEffect 的 dependencies 效能優化、React.memo 的渲染優化。

簡單總結,盡量避免將物件或函式作為 dependencies,但如果真的需要,建議作法為:

  • 當物件或函式為靜態,不依賴 props 或 state 這類 reactive value 時,將物件或函式宣告在 component 外
  • 當物件或函式為靜態,會依賴 props 或 state 這類 reactive value 時,將物件或函式放在 effect 函式內
  • 當函式不放在 effect 函式內,又想放在 component 中,將函式用 useCallback 包住
  • 不將函式放在 dependencies 內,而是抽出函式用到的原始型別(primitive values)作為 dependencies

(參考自:Removing Effect Dependencies

以 linter 輔助填寫 dependencies

React 官方有提供協助開發者偵測並修正 hooks 的 dependencies 的 linter 工具,能在開發階段就給予提示:eslint-plugin-react-hooks,此 ESLint 規則已內建在 Create React App、Next.js 這類開發環境,需要也可自行安裝。

在編輯器使用須搭配 ESLint Plugin,才能看到 linter 警告及使用自動修復功能,以 VS Code 為例,要搭配 VS Code plugin — ESLint

當 linter 看到 dependencies 有缺少或多餘時,就會標示警告:

可使用快速修復功能,自動調整 dependencies:

補充:linter 作為輔助工具,檢查出的警告是可被設為忽略的,但維持對 dependencies 誠實對資料流連動有很大影響,強力建議啟用 hooks dependencies 的 linter 規則檢查,並按照提示修正。
(如果對 dependencies 不誠實才能達到你要的效果,那你要調整的是 effect 函式的邏輯,而非調整 dependencies)

Effect dependencies 常見的錯誤用法

很重要所以再次重申:

  • 📣 useEffect 用途是「將資料同步化到畫面渲染外的副作用處理」,而非 funcction component 的生命週期 API
  • 📣 useEffect 的 dependencies 是「可跳過某些不必要執行」的效能優化,而非用來控制 effect 函式在特定生命週期或特定邏輯下才執行

常有人會誤解,認為將 dependencies 參數加上 [],effect 函式只會被執行一次,之後就永不再被執行,然而,effect 函式在 React 18 可能會在 mount 時被執行兩次,這是 React 18 的 breaking change,只在「嚴格模式」和「開發環境」才會發生,是為了 React 未來版本而規劃的輔助機制。

未來 React 可能會在依賴資料沒更新時,仍重新執行副作用,因此若將dependencies 作為效能優化外用途,副作用會有可靠性疑慮。對 dependencies 誠實不只是一種最佳實踐,而是為了保護程式碼可靠性而須遵循的規範。

常見誤用一:在 function component 中模擬 ComponentDidMount

不該以「在 component 某生命週期的特定時機做特定操作來達到效果」的指令式思維來看待 dependencies,而應該以「由來源資料透過資料流連動反應來同步化副作用處理,無論副作用被執行幾次,應用的行為都應保持正確」。

如果希望副作用只在 component 生命週期只執行一次怎麼辦?應由開發者自己判斷:

import { useState, useEffect, useRef } from 'react';

export default function App() {
const [count, setCount] = useState(0);
const isEffectCalledRef = useRef(false);//以 useRef 儲存布林值 flag 來判斷

useEffect(
() => {
if (!isEffectCalledRef.current) { // 即使 dependencies 是空陣列,此 effect 函式在 React18 嚴格模式仍會執行兩次,但會因為 if 條件判斷而讓裡面邏輯只執行一次
isEffectCalledRef.current = true;
console.log('effect start');
setCount((prevCount) => prevCount + 1);
}
},
[]
);

return <div>{count}</div>;
}

useRef 作為 flag,第一次執行 effect 函式時,isEffectCalledRef.current 是 false 因此可順利執行裡面的邏輯,第二次或之後執行 effect 函式時,isEffectCalledRef.current 是 true 因此裡面的邏輯不被執行,以此達到「商業邏輯在生命週期內只會執行一次」的目的。

useRef hooks 適合儲存實際 DOM element,也適合儲存與畫面沒有連動關係的跨 render 資料,可跨 render 存取的特性十分適合用在此情境下,作為 flag 值來判斷邏輯是否已執行過。關於 useRef 的更多說明可參考官方文章:Referencing Values with Refs

常見誤用二:以 dependencies 來判斷副作用處理在特定資料發生更新時的執行時機

在開發時,很常會有個錯誤觀念(懺悔…就是我😰🙇),就是把 dependencies 當作一個類似監聽的東西,當 dependencies 中項目改變時,才執行某些邏輯。以下是個錯誤範例,當 render 時發現 todos 資料與前一次 render 版本不同,另一個 count state 就要 +1:

import { useState, useEffect } from "react";

export function App() {
const [count, setCount] = useState(0);
const [todos, setTodos] = useState(["foo", "bar"]);

useEffect(() => {
setCount((prevCount) => prevCount + 1);
}, [todos]); // dependencies 不誠實,陣列中寫 effect 函式沒有依賴的變數 todos

//...
}

上述範例明顯錯誤,因它嘗試達到「只有 todos 資料更新時才執行這段副作用處理」,然而未來 React 可能會在 dependencies 與前次 render 相比相同時,仍執行 effect 函式,就會導致額外的 setCount 呼叫,產生非預期 bug。

正確解法應該是自己在 effect 函式內撰寫判斷邏輯,以 useRef 記住前一次 render 的值,自己判斷兩次 render 資料是否相同:

import { useState, useEffect } from "react";

export function App() {
const [count, setCount] = useState(0);
const [todos, setTodos] = useState(["React", "JavaScript"]);
const prevTodosRef = useRef();

useEffect(() => {
if (prevTodosRef.current !== todos) {
//比較前一次 render 的 todos 和這次 render 的 todos,不同時才執行 setCount 邏輯
setCount((prevCount) => prevCount + 1);
}
}, [todos]);// dependencies 誠實,effect 函式依賴 todos 變數

useEffect(() => {
prevTodosRef.current = todos;
//其他副作用處理完後,將本次 todos 資料以 prevTodosRef 儲存起來,供下次 render 使用
}, [todos]);

//...
}

useEffect 以及 dependencies 真的是大~學問,我覺得也是學習 React 時很容易有誤解卡關的地方,另外推薦大家讀以下文章,對 useEffect 會有更深入的理解:

(個人推薦搭配 給新手的 React 學習指南|React 入門官網導讀 這系列影片,PJ 大大的導讀太讚 🚀!)

--

--