[React] React 18 effect 函式執行兩次的原因及 useEffect 常見情境

Monica
19 min readJun 21, 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] 對 hooks 的 dependencies 誠實,以維護資料流的連動,此篇主要敘述的是《React 思維進化》 5–4 ~ 5–5 章節的筆記,若有錯誤歡迎大家回覆告訴我~

React 18 的 effect 函式在 mount 時為何會執行兩次?

上篇文章曾提及,常有人會誤解,認為將 dependencies 參數加上 [],effect 函式只會被執行一次,之後就永不再被執行。如下程式碼,你可能預期 count 只會被更新一次,因此畫面會顯示 1

import { useState, useEffect } from 'react';

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

useEffect(
() => {
console.log('effect start');
setCount((prevCount) => prevCount + 1);
},
[]
);

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

實際上,這 effect 函式在 React 18 可能會在 mount 時被執行兩次,導致顯示出的 count 變成 2。這是 React 18 的 breaking change,只在「嚴格模式」和「開發環境」才會發生;若取消「嚴格模式」,或在 production 正式環境,則不會發生。

嚴格模式是由 <StrictMode> 來包住整個應用,以啟用嚴格模式。

import { StrictMode } from 'react';
import ReactDOM from "react-dom/client";
import App from './App';

const root = ReactDOM.createRoot(
document.getElementById("root-container")
);

root.render(
//以 <StrictMode> 來包住整個應用,以啟用嚴格模式
<StrictMode>
<App />
</StrictMode>
);

補充:嚴格模式(strict mode)
- React 用來檢測潛在問題的開發工具,只在開發環境有效
- 嚴格模式時,React 會以一些特殊行為檢查程式碼是否足夠可靠,如:自動執行兩次
setState 的 updater function、自動 mount 兩次 component

而為何 React 18 嚴格模式要執行兩次 effect 函式? 這和 React 未來版本規劃以及 reusable state 概念有關。

Reusable state

React 未來版本新功能皆基於一個要求:「component 須設計得足夠彈性,多次 mount 與 unmount 也不會壞掉」。

檢視「component 多次 mount 與 unmount 也不會壞掉」這件事,可分為兩點:

  • 畫面渲染:以宣告式呈現,不會有太多問題 👌
  • 副作用處理:若副作用重複執行後會出錯,則無法滿足此要求 ⚠️

因此我們須特別關注副作用處理的彈性,以滿足這項要求。

有哪些功能須基於「component 多次 mount 與 unmount 也不會壞掉」的要求?

  1. Fast Refresh (hot module replacement)
    現有的 Fast Refresh 功能已包含「component 多次 mount 與 unmount 也不會壞掉」的要求。
    Fast Refresh 是開發環境工具(如:Next.js)常有的功能,其功能是當我們在開發環境編輯程式碼時,每次存檔後,瀏覽器可在不重整頁面的情況即時套用程式碼的變動。而它是如何做到這功能? 就是在每次存檔時 unmount component,再立即以新版 component 程式碼重新 mount,過程中保留 component 的 state,不會因 unmount 被清除。
  2. Offscreen API
    React 新功能 Offscreen API 也須滿足此要求才能運作。
    Offscreen API 功能是在畫面切換時保留 component 的 state 和對應 DOM element,先暫時隱藏而非移除。當 component 需再次被顯示時,就能以之前保留的 state 再次 mount,以此提升效能。

「保留 component state 狀態以便需要時能快速還原並再次 mount」就稱為「reusable state」。而「reusable state」就代表:「component 可能會在生命週期裡被 mount 和 unmount 不只一次」。

而再次 mount 時就會再執行副作用處理,代表即使 dependencies 資料沒更新,effect 函式仍可能再次被執行。

為確保 effect 函式再次執行也不會出錯,副作用處理就應設計得安全、有彈性,而副作用處理如何才足夠有彈性、安全? 就是要讓副作用無論執行多少次,也不會出錯。

因上述原因,React 18 開發環境下的嚴格模式會以「mount → unmount → mount」流程來模擬多次 mount 行為,以此檢查副作用處理是否足夠彈性可靠,也因此我們才會看到「執行 effect 函式 → 執行 cleanup 函式 → 執行 effect 函式」的行為。

初學 useEffect 時,很容易對副作用有誤解而寫出不夠安全的副作用處理,但從今以後!我們要轉換心智模型,以更安全可靠方式設計副作用處理,以免 React 版本更新後,我們的副作用處理全部壞掉…😢。

關於 reusable state 更多說明可參考這篇:Adding Reusable State to StrictMode

useEffect 常見情境設計技巧

一個理想的副作用處理,其影響應該是可逆的,無論執行幾次副作用,都不會出錯。

在處理副作用時,有幾個常見問題:

  • 疊加性質而非覆蓋性質的操作
    副作用的影響會隨多次執行而不斷疊加,若沒有 cleanup 函式清除影響,會造成預期外結果
  • Race condition(競態條件)問題
    副作用處理涉及非同步影響時,副作用多次執行的順序不一定和非同步事件的回應順序相同,導致 race condition 問題
  • Memory leak 問題
    副作用啟動監聽工作,卻沒有對應的取消訂閱,在 component unmount 後可能仍會持續監聽,導致 memory leak 問題

上述問題該如何解決? 這些問題都可透過適當的 cleanup 函式解決,由 cleanup 函式來停止或抵消 effect 函式造成的影響。

Fetch 請求伺服器端 API

請求後端 API 是副作用處理的常見情境,以下是一個 fetch 後端 API 範例:

import { useState, useEffect } from "react";
import fetchArticleData from "./fetchArticleData";

export default function ArticleContent(props) {
const [articleData, setArticleData] = useState(null);

useEffect(() => {
async function startFetching() {
const data = await fetchArticleData(props.articleId);
setArticleData(data);
}
startFetching();
}, [props.articleId]);
//...
}

乍看之下可能覺得沒問題,但其實這程式碼會出現一個問題:當 effect 函式短時間內被連續執行,先被觸發的 fetch 不一定會比後被觸發的 fetch 更早回傳結果,因為 API 回傳時間是不固定的,而這就會造成 race condition 問題。

模擬 race condition 流程如下:

  1. component 第一次 render,props.articleId1
    - 執行此次 render 的 effect 函式,其中會執行fetchArticleData(1),而 fetch 為非同步,結果不會立刻回傳,因此還不會執行 setArticleData
  2. fetchArticleData(1) 非同步還沒回傳結果前,component 因父元件傳入新 props 連帶 re-render
  3. component 第二次 render,props.articleId2
    - 比較 useEffect 的 dependencies,發現本次 render 的 props.articleId2,與前次的 1 不同,因此要執行副作用處理
    - 執行此次 render 的 effect 函式,其中會執行fetchArticleData(2),而 fetch 為非同步,結果不會立刻回傳,因此還不會執行 setArticleData
  4. 過段時間後,fetchArticleData(2) 非同步事件先完成並回傳結果
    - 執行 setArticleData(/* articleId 為 2 的資料 */)
  5. 過段時間後,fetchArticleData(1) 非同步事件完成並回傳結果
    - 執行setArticleData(/* articleId 為 1 的資料 */)

上述這流程有何問題? 在這過程中,fetchArticleData(1) 的結果較晚回傳,因此較晚執行 setArticleData,即使副作用處理有在 props.articleId 更新時重新執行,最後 state 留下的卻是舊的請求結果。

示意圖如下:

race condition 流程

順帶一提,即使 fetchArticleData(1)先回傳結果,而 fetchArticleData(2) 較晚才回傳結果並呼叫 setArticleData(/* articleId 為 2 的資料 */),這情況下 articleData 可以拿到最新的請求結果,但中間的 setArticleData(/* articleId 為 1 的資料 */) 可能會讓畫面閃過一瞬間的舊資料,這對使用者體驗也是相對不友善的~

補充:race condition(競態條件)
- race condition 指的是多個操作的相對時間順序會影響程式存取資源的行為,可能導致非預期結果
- 通常發生在多個操作短時間內執行或存取同一份資源時,難以預期是哪個先完成,導致結果不如預期
- 須採特定措施以確保操作按照既定順序進行

要如何解決 fetch 的 race condition 問題? 可用 abort fetch 或忽略舊請求結果的方式處理,以下範例是用 flag 來記憶是否需忽略請求結果:

import { useState, useEffect } from "react";
import fetchArticleData from "./fetchArticleData";

export default function ArticleContent(props) {
const [articleData, setArticleData] = useState(null);

useEffect(() => {
let ignoreResult = false; //每次 render 的 effect 函式都宣告一個 flag,用來記憶是否要忽略本次 render 的回傳結果

async function startFetching() {
const data = await fetchArticleData(props.articleId);
if (!ignoreResult) {
setArticleData(data); // 如果不要忽略(ignoreResult 為 false),再將回傳的資料存到 state,否則直接忽略
}
}

startFetching();

return () => {
ignoreResult = true; //cleanup 時,將 ignoreResult 改為 true,再次執行副作用時就會將前次 effect 函式的 flag 更新,避免舊的資料事後才存進 state
};
}, [props.articleId]);
//...
}

每次 render 的 effect 函式都記得「是否該忽略此次 fetch 結果」的 flag,ignoreResult 預設為 false,代表預設會儲存 fetch 的結果;而若遇到 re-render,執行新 effect 函式前會執行前一次的 cleanup 函式,將前次 effect 函式的 ignoreResult 改為 true,就不會儲存前一次 fetch 的結果。

流程如下:

  1. component 第一次 render,props.articleId1
    - 執行此次 render 的 effect 函式
    ▪︎ 宣告 ignoreResult (第一次 render effect 函式內的)為 false
    ▪︎ 執行fetchArticleData(1),而 fetch 為非同步,結果不會立刻回傳,因此還不會執行 setArticleData
  2. fetchArticleData(1) 非同步還沒回傳結果前,component 因父元件傳入新 props 連帶 re-render
  3. component 第二次 render,props.articleId2
    - 比較 useEffect 的 dependencies,發現本次 render 的 props.articleId2,與前次的 1 不同,因此要執行副作用處理
    - 執行前一次 render 的 cleanup 函式,更新 ignoreResult (第一次 render effect 函式內的) 為 true
    - 執行此次 render 的 effect 函式
    ▪︎ 宣告 ignoreResult (第二次 render effect 函式內的) 為 false
    ▪︎ 執行fetchArticleData(2),而 fetch 為非同步,結果不會立刻回傳,因此還不會執行 setArticleData
  4. 過段時間後,fetchArticleData(2) 非同步事件先完成並回傳結果
    - 第二次 render effect 函式內的 ignoreResult 仍為 false,會執行 etArticleData(/* articleId 為 2 的資料 */)
  5. 過段時間後,fetchArticleData(1) 非同步事件完成並回傳結果
    - 第一次 render effect 函式內的 ignoreResult 為 true,忽略回傳結果,不做任何處理

示意圖如下:

以 flag 儲存是否忽略回傳結果,解決 race condition 問題

cleanup 函式在 unmount 時也會執行,此方法也可避免「component unmount 後,fetch 才回傳結果並呼叫 setArticleData 而導致 React 發生錯誤」的問題。

第三方套件解決方案

請求 API 的情境,實務上推薦直接使用第三方套件來處理,套件除了處理 race condition 問題,也會進行效能調校,熱門套件如:

控制外部套件

有時會在 React 專案使用非 React 建構的套件,需設計副作用來和第三方套件互動。以下為一個初始化第三方地圖服務的串接範例。

import { useEffect, useRef } from "react";
import { createMapManager } from "fake-map-sdk";

export default function App() {
const mapManagerRef = useRef(null);

useEffect(
() => {
if (!mapManagerRef.current) {
//自己撰寫條件邏輯來確保 mapManager 的初始化不會被重複執行
mapManagerRef.current = createMapManager();
}
},
[] //因為沒有資料依賴才填空陣列,不是為了控制 effect 函式發生時機
);

//...
}

初始化第三方套件時,建議做法是將初始化流程放到 React 應用頂層 component 中、或頂層 component 外,確保初始化只執行一次,減少專案內重複的外部套件初始化。

若要將資料流同步化到外部套件:

import { useState, useEffect } from "react";

export default function MapViewer({ mapManager }) {
const [zoomLevel, setZoomLevel] = useState(0); //使用者操作來控制 zoomLevel 的值

useEffect(() => {
//將 React 維護的 zoomLevel 值同步到外部套件 mapManager,真正控制地圖套件的縮放
mapManager.setZoomLevel(zoomLevel);
}, [zoomLevel]);

//...
}

zoomLevel 變動太頻繁,可考慮用 throttle 做效能優化。

補充:throttle
- 當一個事件或函式太頻繁發生,throttle 可確保它在設定的時間間隔內只執行一次,通常用於重視效能、資源有限情況
- 應用情境如:瀏覽器中的滾動事件、網路 API 請求

監聽或訂閱事件

訂閱 DOM 事件或自定義事件是常見副作用處理,需注意的是,要記得在 cleanup 函式處理對應的取消訂閱,避免 component unmount 後,訂閱仍持續運作,造成 memory leak 問題。

import { useEffect } from "react";

export default function App() {
const handleScroll = () => {
// do something...
};

useEffect(() => {
window.addEventListener("scroll", handleScroll);

return () => {
//🔔 在 cleanup 函式取消事件訂閱,逆轉副作用的影響
window.removeEventListener("scroll", handleScroll);
};
}, []);

//...
}

不該是副作用處理:使用者操作所觸發的事情

有時即使嘗試撰寫 cleanup 函式仍無法清除 effect 函式的影響,如以下範例,在 effect 函式打 API 說要在文章底下留言:

import { useEffect } from "react";

export default function App() {
useEffect(() => {
//⛔️ 此副作用影響層面涉及伺服器、資料庫,無法透過 cleanup 函式逆轉
fetch("/api/comment", { method: "POST" });
}, []);
//...
}

如何解決? 就是不要用 useEffect XD,我們不應把對應「使用者操作行為」的處理放在 effect 函式內,而應把它放在使用者觸發的事件中。

export default function App() {
const handleClick = () => {//透過留言按鈕觸發 event
fetch("/api/comment", { method: "POST" });
};

return <button onClick={handleClick}>Comment</button>;
}

關於各種不該是副作用處理的情境,很推大家讀官方的這篇文章 You Might Not Need an Effect,裡面提及很多開發時會有的誤會,讓我發現原來我以前都在濫用 useEffect…(再次懺悔😰🙇),各種不必要的 useEffect 如:

  • ⛔️ 根據兩個 state 計算另一個變數的值
  • ⛔️ 當 props 改變時重設所有 state
  • ⛔️ 連續運算鏈

更詳細的說明就請大家去讀該文章囉~

--

--