《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 都會被重新產生,就無法反映資料是否真的有更新,副作用因而無法判斷源頭資料是否有更新,而一律判定為有發生改變:

為了讓函式正確反映資料是否更新,我們可用 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 處理函式,來解決 useEffect 中 dependencies 效能優化失效的問題:

補充:
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 大大的導讀太讚 🚀!)
Reference:#
- https://react.dev/learn/referencing-values-with-refs
- https://react.dev/reference/react/useEffect
- https://react.dev/learn/lifecycle-of-reactive-effects
- https://react.dev/learn/removing-effect-dependencies
如有任何問題歡迎聯絡、不吝指教✍️