# [Day 21] useEffect 其實不是 function component 的生命週期 API ~ [Day 26] Effects & cleanups 常見情境的設計技巧
###### tags: `閱讀筆記` `iT 邦` `一次打破 React 常見的學習門檻與觀念誤解`
> Each Render Has Its Own… Everything. *[ref.](https://overreacted.io/a-complete-guide-to-useeffect/)*
> 每一次 render 元件都擁有自己的資料(state, props, effect functions, cleanups, functions...) => 每一次 render 就是自己一個 scope
## useEffect 執行的時機點
避免畫面渲染阻塞,React 會在當前元件載入(render)完畢後(瀏覽器完成 DOM 的繪製後)才會執行 effect functions。如有 cleanup 時則是會先叫用 cleanup 才會執行 effect functions。
- 所以預設情況下(沒有給 dependency array 的情況下,只要元件重新被叫用後及 DOM 繪製完畢後,就一定會叫用 (cleanup ->) effect functions
## dependency array 應以效能優化的手段出發,而不是以「什麼時候可以叫用」effect functions 為導向
因為在預設情況下,只要每次元件載入後就會執行 effect functions,但在某些時刻就會造成資源浪費:
```javascript!
// ref. https://overreacted.io/a-complete-guide-to-useeffect/
function Greeting({ name }) {
const [counter, setCounter] = useState(0);
/* 每次元件渲染完畢後就會叫用這個 effect function */
useEffect(() => {
document.title = 'Hello, ' + name;
});
return (
<h1 className="Greeting">
Hello, {name}
<button onClick={() => setCounter(count + 1)}>
Increment
</button>
</h1>
);
}
```
```javascript!
// ref. https://overreacted.io/a-complete-guide-to-useeffect/
function Greeting({ name }) {
/* 點擊按鈕後元件重新叫用渲染得到 counter = 2 */
const counter = 2
/* 這裡的 effect function 還是會被叫用 */
/* 每次元件渲染完畢後就會叫用這
document.title = 'Hello, ' + name;
});
return (...);
}
```
這時候就可以使用 dependency array,提供 React 「優化效能的手段」,讓 effect functions 不會每次在元件載入後就叫用:
```javascript!
useEffect(() => {
document.title = 'Hello, ' + name;
/*
* 提供 React 優化效能的手段,在元件載入完畢後 React 會比對 dependency array 有無不同,
* 有的話才會叫用 effect functions,沒有的話就直接省略不執行 effect functions
* */
}, [name]);
```
> 比對的方式與 `useState` 相同,是使用 `Object.is()` 比對是否需要執行 effect functions。
> 應該是以「放入 `[name]` 是優化不必要叫用 effect function 的手段,而不是將 dependency array 當作是是否要叫用 effect function 的邏輯閘門(認作當 `[name]` 變動時才執行 effect function)。
## 對 dependency array 誠實
在非 `useEffect` 透過 scope 的原理向外找到的資料,記得就放進 dependency array 之內。
### 函示定義在 `useEffect` 之內
依靠 `useEffect` 是透過 dependency array 達到優化的手段,可以反推回來:如果一個函式只需叫用一次 -> 定義在 `useEffect()` 可以省略因為 render 而重複地被建立:
```javascript!
// ...
const [data, setData] = useState()
useEffect(() => {
// 這裡的函式會因為給予 [] 而優化成只會建立一次及叫用一次
async processGetSomeApiRequest = () => {
const response = await fetch('...')
const data = await response.json()
useState(data)
}
processGetSomeApiRequest()
}, [])
```
### pure function 可以定義在元件之外,以達到共用的效果
### `useCallback` 為 `useEffect` 的效能優化提供很大的幫助
> 注意:`useCallback` 本身並不是省略建立重複的 inline function,而是我們可以直接拿到當初被建立的函式。==所以還是會建立,只是我們拿不到該次 render 時建立的函式而已。==
## 需要被清理的 effect functions -> clean up
> clean up 是 `useEffect` 回傳的函式。
> 當 component unmounted 及 render 後要執行下一次 effect functions 時,都會叫用上一次 rende(scope) 的 clean up。
### 取消事件
取消建立的事件,避免 component unmounted 時事件還繼續存在,造成效能浪費:
```javascript!
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
// 當 component unmounted 時取消 window scroll 事件
return () => window.removeEventListener('scroll', handleScroll);
// 因為沒有 dependency 才給空陣列,並不是以「限定叫用一次 effect function」才給空陣列
}, []);
```
### 發起請求的順序並不代表接收到回應的順序
發起請求的順序並不代表收到回應的順序,當取得資料的順序不如預期時,就會造成 UI 顯示錯誤的資訊:
```javascript!
// ref. https://react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development
const [person, setPerson] = useState('Alice');
const [bio, setBio] = useState(null);
useEffect(() => {
setBio(null);
fetchBio(person).then(result => {
setBio(result);
});
}, [person]);
// UI 有對應事件讓 person 可以隨著使用者選擇時改變
const handleChangePerson = (event) => {
setPerson(event.target.value)
}
```
```javascript!
// ----- 元件第一次 render 時 -----
person('Alice')
bio(null)
// DOM 繪製完畢執行 effect functions
bio(null)
// 發起請求(收到回應後)
bio(result)
// 使用者透過 UI 變更 person
person('Bob')
// ----- 元件 re-render(因為 Object.is('Alice', 'Bob') => false,所以觸發元件 re-render -----
person('Bob')
// DOM 繪製完畢執行 effect functions
bio(null)
// 使用者再次透過 UI 變更 person
person('Tom')
// ----- 元件 re-render(因為 Object.is('Tom', 'Bob') => false,所以觸發元件 re-render -----
person('Tom')
// DOM 繪製完畢執行 effect functions
bio(null)
// 發起請求(收到回應後),因為發起請求的順序 !== 取得結果的順序,有可能 Tom 的 bio 先來
bio(result)
// 發起請求(收到回應後),之後再取得 Bob 的結果,造成 UI 上顯示錯誤(因為現在 UI 是選擇 Tom,但是因為取得 Bob 的回應比 Tom 慢)
bio(result)
```
這時候可以透過 `flag` + functional scope + clean up functions 的執行順序,避免等待取得 API 資料時造成的 UI 錯誤:
```javascript!
// ref. https://react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development
const [person, setPerson] = useState('Alice');
const [bio, setBio] = useState(null);
useEffect(() => {
// 增加 flag 判斷是否需要重新洗掉 state
let shouldBeIgnored = false
setBio(null);
fetchBio(person).then(result => {
if (!shouldBeIgnored) {
setBio(result);
}
});
// 透過 clean up function 更新 flag,避免 UI 顯示錯誤
// 每一次 render 都有自己的 state, event handler, effect functions and clean up functions
return () => shouldBeIgnored = true
}, [person]);
// UI 有對應事件讓 person 可以隨著使用者選擇時改變
const handleChangePerson = (event) => {
setPerson(event.target.value)
}
```
```javascript!
// ----- 元件第一次 render 時 -----
person('Alice')
bio(null)
// DOM 繪製完畢執行 effect functions
shouldBeIgnored = false // (scope 1)
bio(null)
// 發起請求(收到回應後)
bio(result)
// 使用者透過 UI 變更 person
person('Bob')
// ----- 元件 re-render(因為 Object.is('Alice', 'Bob') => false,所以觸發元件 re-render -----
person('Bob')
// DOM 繪製完畢要執行 effect functions 前會先執行上一次 render 時的 clean up function
shouldBeIgnored = true // (scope 1)
// 上一次 render 時的 clean up function 完畢執行 effect functions
shouldBeIgnored = false // (scope 2)
bio(null)
// 使用者再次透過 UI 變更 person
person('Tom')
// ----- 元件 re-render(因為 Object.is('Tom', 'Bob') => false,所以觸發元件 re-render -----
person('Tom')
// DOM 繪製完畢要執行 effect functions 前會先執行上一次 render 時的 clean up function
shouldBeIgnored = true // (scope 2)
// 上一次 render 時的 clean up function 完畢執行 effect functions
shouldBeIgnored = false // (scope 3)
bio(null)
// 發起請求(收到回應後)(Tom 先取得回覆)
// 因為當前 effect 中 if(!shouldBeIgnored) 成立,因此會執行 bio(result),進而再導致元件因為 state 與歷史不同而重新 re-render
bio(result)
// 發起請求(收到回應後)(Bob 才取得回覆)(因為 Bob 的 clean up 已經在執行 Tom effect functions 前變更 flag,所以不會滿足 if(!shouldBeIgnore) 的條件,因此不會重新叫用 bio(result),因此 UI 並不會顯示錯誤
bio(result)
```
#### 初始化第三方套件的實例
有時候使用其他第三方套件需要先建立其實例,之後開發者就可以針對該實例操作。可以透過 `useEffect` + clean up functions 處理實例的建立及銷毀:
```javascript!
// ref. https://ithelp.ithome.com.tw/articles/10307558
useEffect(
() => {
if (!mapRef.current) {
mapRef.current = new FooMap();
}
return () => {
if (mapRef.current) {
mapRef.current.destory()
}
}
},
// 這裡是因為沒有任何依賴才填空陣列,
// 而不是為了控制 effect 只執行一次
[]
);
```
如果知道只需要一個實例 + 橫跨整個 APP 使用的話,最好的方式就是移至最上層(根層元件),或者跳脫元件外初始化,避免因為 re-render 的問題重新叫用元件進而導致重複建立實例,造成設定上因為實例不同而產生預期之外的 bugs。
## 需要靠使用者觸發的事情不屬於 effect functions
```javascript!
// ref. https://react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development
// 這樣子當 DOM 渲染完就一定會叫用 buy api
useEffect(() => {
fetch('/api/buy', { method: 'POST' });
}, []);
```
這種需要靠使用者觸發的行為不需要放在 effect functions 中,而是放在 event handler 內,由使用者觸發對應的行為。
## 小考題
### 1. 請回答下列的 `console.log()` 印出的順序(未使用 `Strict Mode`)
[[Note] clean up 也是等到 DOM 渲染後才會觸發](https://codesandbox.io/p/sandbox/note-clean-up-ye-shi-deng-dao-dom-xuan-ran-hou-cai-hui-chu-fa-sgfdfx?file=%2Fsrc%2FApp.js)
```javascript=
import React, { useState, useEffect } from "react";
function App() {
const [count, setCount] = useState(0);
console.log("A");
useEffect(() => {
console.log("B");
return () => {
console.log("C");
};
}, [count]);
console.log("D");
useEffect(() => {
console.log("E");
});
useEffect(() => {
console.log("F");
}, [count]);
const increment = () => {
setCount(count + 1);
};
return (
<div>
<TestC />
<h1>Count: {count}</h1>
<button onClick={increment}>Increment</button>
</div>
);
}
```
#### 第一次元件載入
為了避免頁面渲染堵塞,所以 effects 會等到該 scope 渲染完畢才會執行,所以印出的順序為 `A, D, B, E, F`(同一 scope 的 effects 按照定義的順序執行)。
#### 使用者透過 UI 更新 state
==為避免頁面渲染堵塞==,所以即便有 clean up,還是會先讓頁面渲染完,才會執行上一 scope 的 clean up,才會執行新 scope 的 effect,所以印出的順序為 `A, D, C, B, E, F`(`C` 為上一次 scope 的 clean up)。
## Recap
- effect functions 預設是隨著每次 render 後(DOM 繪製完畢後)觸發
- useEffect 的 dependency 是優化效能的手段為思考點,而不是以什麼時候可以叫用的邏輯判斷
- 如果 dependency 經過 `Object.is()` 比對與上一次歷史的 dependency 相同時,React 就會自動跳過該次 effect function 的執行
- 每一次 render 都是自己的 scope,因此有自己全部的東西(props, event handlers, effect functions, clean up function, variables 等等)
- 再執行該次的 effect functions 之前,會先叫用上次 render 時建立的 clean up function
```javascript!
執行 clean up (scope 1) => 執行 effect functions(scope 2) => 執行 clean up functions (scope 1) => 執行 effeact functions (scope 2) ....
```
- 對 dependency array 保持誠實,不屬於 effect functions scope 的東西需要放進 dependency array 內
## 參考資料
1. [[Day 21] useEffect 其實不是 function component 的生命週期 API](https://ithelp.ithome.com.tw/articles/10305220)
2. [[Day 22] 保持資料流 — 不要欺騙 hooks 的 dependencies(上)](https://ithelp.ithome.com.tw/articles/10305701)
3. [[Day 23] 保持資料流 — 不要欺騙 hooks 的 dependencies(下)](https://ithelp.ithome.com.tw/articles/10306185)
4. [[Day 24] useEffect dependencies 的經典錯誤用法](https://ithelp.ithome.com.tw/articles/10306703)
5. [[Day 25] Reusable state — React 18 的 useEffect 在 mount 時為何會執行兩次?](https://ithelp.ithome.com.tw/articles/10307083)
6. [[Day 26] Effects & cleanups 常見情境的設計技巧](https://ithelp.ithome.com.tw/articles/10307558)
7. [A Complete Guide to useEffect](https://overreacted.io/a-complete-guide-to-useeffect/)
8. [30天React練功坊-攻克常見實務/面試問題 Day21: React render logic interview question](https://ithelp.ithome.com.tw/articles/10335648)