React 的那些事 — useEffect

Lee Luciano
10 min readFeb 8, 2023

--

今天要來聊聊新手時期最痛恨的hook function — useEffect。

首先,讓我們先來了解一下 useEffect 的誕生是為了處理怎樣的問題吧!

有沒有這樣的情況,比如在 useState 那裡曾經介紹過的 search 功能,當我們設定的 state 經過更動後,想要讓他自動地跑一些其他連帶的 function。

又或者更常見的 api 串接工作,幾乎要跟 useEffect 綁在一起了,在 React-query & SWR 還不普及的原生年代,幾乎大部分的教學都告訴你要用 useEffect 去打 api,但其中的原理大多要追朔到上個世紀的 class component 作法,也就是整個 React 演化的歷史。

很多初學的新手,在對 javascript 的理解上並不熟悉,在早期 class component 的環境中都是以物件形式來區分 render 時機點,也就是說所有渲染時機都有對應的位置和 function 可以去做更動,但缺點就是很攏長,也顯得不符合大多數純渲染狀態的 component 的製作。

下圖為常見的教學會講到的渲染時機圖(圖片出處)

於是 functional component 的出現解決了許多不便,但其連帶的 hooks function 設計理念也是從 class component 的使用情境下思考,並不像 solid 那樣重新設計避開 side effect 的使用問題,這也導致 useEffect 並不像其他 hooks 那樣單純,因為他本質上就是用來處理 side effect 的 hook。

下圖為hook function對應的生命週期(圖片出處):

從圖面可知,它是一個橫跨生命週期中三個渲染時機的hook function,所以自然比其他的hook function來得複雜,讓我們看看他的範例吧!

useEffect(() => {
// update function對應的是component updating。
const timer = window.setInterval(() => {
setCount((pre) => pre + 1)
}, 1000)
// clean up function 對應的原本的 componentWillUnmount() 的地方
return () => window.clearInterval(timer)
// 第二個參數為 dependencies array,在陣列裡面的參數會使得 useEffect 去判斷該欄位的值是否需要更新,
// 如果需要則會依據該參數是否更新而觸發第一個帶入的 update function 而執行重新渲染。
// 如果不帶的話會造成 error,這邊我就不多做解釋了
}, [])

那麼,以上的基本概念是怎麼搞混的呢?讓我們試者加入常用情境 — 串接 api:

useEffect(() => {
// 這裡是避免 react.18 二次渲染的解法,很多朋友都是直接處理 fetch(),
// 也就是沒有處理 clean up 的部分,那這裡就是處理fetch clean function 的做法
const controller = new AbortController();
const signal = controller.signal;
fetch(API_URL, {
signal: signal
})
.then((response) => response.json())
.then((response) => {
// 成功之後的處理
});
return () => {
// 清除 request componentWillUnmounts
controller.abort();
};
}, []);

看起來都能夠正常運行對吧!但如果我會因為 state change 而重新判斷他需不需要去重新取資料呢?如下:

// const [params, setParams] = useState("")
// 上面的話不會有問題,但下面的作法就會有問題了
const [params, setParams] = useState({count: "", limit: ""})

useEffect(() => {
// 這裡是避免 react.18 二次渲染的解法,很多朋友都是直接處理 fetch(),
// 也就是沒有處理 clean up 的部分,那這裡就是處理fetch clean function 的做法
const controller = new AbortController();
const signal = controller.signal;
fetch(API_URL + params, {
signal: signal
})
.then((response) => response.json())
.then((response) => {
// 成功之後的處理
});
return () => {
// 清除 request componentWillUnmounts
controller.abort();
};
}, [params]);


// 這個模擬params change
// const onParamsChange = (e) => setParams(e.target.value); // 對應params為string
const onParamsCountChange = (e) => setParams((pre) => {...pre, count: e.target.value});
const onParamsLimitChange = (e) => setParams((pre) => {...pre, limit: e.target.value});

如果 params 只單純是字串的話,功能還不會有問題,但是如果是物件或是陣列格式的話就必須要注意了,因為在 dependencies array 裡面是採 shallow compare 的機制,簡單來說就是採 js 的比較機制,也就是新手常常搞混的地方,下面為簡單解釋:

5 === 5; // true
5 === 10; // false

true === true; // true
true === false; // false

"hello" === "hello"; // true
const hello = "hello"; // 更換指向
hello === "hello" // true

({a: "a"}) === { a: "a" }; // false,
// 因為 js 在物件型別的處理上是採 by reference,
// 所以即便 key & value 都相等也不等於相同物件
const obj = { a: "a" };
obj === obj; // true
obj === { a: "a" }; // false

[] === []; // false
// 相同的情況也發生在陣列上
[5] === [5]; // false
// 那react怎麼判斷dependencies array內部的否相等呢,如下:
const deepEqual = (old, new) => old.length === new.length && old.every((el, i) => el === new[i])

deepEqual([], []); // true
deepEqual([5], []); // false
deepEqual([5], [5]); //true
deepEqual([true], ["true"]); // false
deepEqual([hello], [hello]); // true
deepEqual([obj], [obj]); // true

const copyObj = obj;
deepEqual([obj], [copyObj]); // true
deepEqual([obj], [{ a: "a" }]); // false,因為 reference 是不同的
// 這就是很多使用上的錯誤盲區,它會判斷裡面的內容,但基本上仍然是參照 js 的判斷機制
// 所以在 dependencies array 內部,必須盡量避開 by reference 的判斷情境
// 如果是物件看能不能往下取一層就好,
// 如果必要用到物件或陣列整個來判斷你的 updating function 是否需要執行的話,
// 考慮採用 useMemo 或 useCallback 額外做處理。

那麼讓我們再回頭修正一下之前的問題吧!

const [params, setParams] = useState({count: "", limit: ""})

useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
if(API_URL) { // 這裡我加了判斷,避免參數沒有接到fetch會失敗
fetch(API_URL + `?count=${params.count}&limit=${params.limit}`, {
signal: signal
})
.then((response) => response.json())
.then((response) => {
// 成功之後的處理
});
return () => {
controller.abort();
};
}
}, [API_URL, params.count, params.limit]);
// 這裡就不能只擺 params 會有剛才提到的問題
// 必須往下取到 by value 的值

// 這個模擬params change
const onParamsCountChange = (e) => setParams((pre) => {...pre, count: e.target.value});
const onParamsLimitChange = (e) => setParams((pre) => {...pre, limit: e.target.value});

注意事項
1. 不要忘記 dependencies array 的存在。
2. 要注意你放進 dependencies array 中的參數型別,如果是 by reference 的資料(ex: object, array…), 要記得額外用 useMemo 或 useCallback 來處理。
3. 如果不想看到 React 18 渲染兩次的話,記得要處理 clean up function。

以上大概就是我所能分享的部分了,希望能幫助到大家,很多時候在學習 React 的過程中會覺得很難的原因,其實是 javascript 的問題,當然也有一些後起之秀 solidjs, svelte, preact…,能處理得很方便,並且優化了很多 mounting & unmounting 的問題,但源頭也都是吸收 React 去改良,所以能多了解一些,再跳去新興框架的時候自然會覺得容易許多。

--

--