React 學習筆記(2) - useEffect, useCallback, fetch data
===

###### tags: `React`
---
## Fetching Data && useEffect
- 讓我們來學習在 React 裡面 fetch json 資料,首先先建立一個 json 資料
```json=
{
"trips": [
{
"title": "2 Night Stay in Venice",
"price": "£195"
},
{
"title": "3 Night Stay in Paris",
"price": "£295"
},
{
"title": "4 Night Stay in London",
"price": "£345"
},
{
"title": "3 Night Stay in New York",
"price": "£325"
}
]
}
```
- 在 React 專案資料夾的根目錄建立一個 data 資料夾,將 json 檔案置入並命名 `db.json`
- 接著在 src 資料夾內建立一個 components 資料夾,並建立一個 `TripsList.js` 的子元件
- 開啟終端機,在全域安裝 `npm install -g json-server`,這個 package 讓我們可以建立一個 API server,請參考[這篇](https://github.com/typicode/json-server)
- 安裝完畢後,輸入 `json-server --watch ./data/db.json`,就會自動為我們建立一個本地端的連線如下:

- 接著打開 `https://localhost:3000/trips` 就可以看見我們的 json 文件

- 接著打開 Trips.js 元件來 fetch 這個資料
```javascript=
// Trips.js
import React from "react";
export default function TripList() {
fetch("http://localhost:3000/trips")
.then((res) => res.json())
.then((data) => console.log(data));
return <div>Trip List</div>;
}
```
- 另開終端機輸入 `npm run start` 來建立 React app 的本地端伺服器,但因為 json-server 已經佔用了 3000的埠號,因此會出現如下的提示訊息,只要點擊 y 另外建立一個埠號即可

- 開啟 React app 的本地端伺服器後,打開 develop tool 的 console,會看見我們稍早前 console.log(data)

---
### 使用 useState 為什麼讓資料無限循環產出~
- 將 fetch 到的資料儲存在 state 裡面,並逐一渲染出來,以下範例使用 useState
```javascript=
// Trip.js
import React, {useState} from "react";
export default function TripList() {
const [trips, setTrip] = useState([]);
console.log(trips)
fetch("http://localhost:3000/trips")
.then((res) => res.json())
.then((data) => setTrip(data));
return <div>Trip List</div>;
}
```
- 資料竟然無限增生,為什麼呢?
- **原因是因為 React 的運作方式,會不斷地注意更新的 state,程式碼中當第一次 fetch 到資料,然後運行 `setTrip(data)`,接著再 console.log 出來,接著再繼續 fetch,因為並沒有任何一段程式碼阻斷,所以就不斷輸出結果。**

---
### 使用 useEffect
```javascript=
import React, { useState, useEffect } from "react";
export default function TripList() {
const [trips, setTrips] = useState([]);
// 使用 useEffect 來避免重覆 render
useEffect(() => {
// fetch json data
fetch("http://localhost:3000/trips")
.then((res) => res.json())
.then((data) => setTrips(data));
// 給予第二個參數為空陣列
}, []);
console.log(trips);
return <div>Trip List</div>;
}
// 我們在第一行引入了 useEffect
// 設定 state 之後,立即執行 useEffect,第一次執行時,為空陣列,等到 fetch 到資料後,更新 state ,再渲染出來
```
- 第一個結果為空陣列,接著才是出現 fetch 的結果,基本上類似這樣需要在一開始就做 data fetch 的部分很適合使用 useeffect + empty dependency array

- 如果把第二個參數空陣列拿掉,一樣會出現無限增生的 fetch 資料,要特別注意。
- 參考資料:[PJCHENder](https://overreacted.io/a-complete-guide-to-useeffect/)、 [Overeacted](https://overreacted.io/a-complete-guide-to-useeffect/)、[Devtrim](https://devtrium.com/posts/dependency-arrays)
---
### 把 List 渲染出來
- 使用 map 產生新的 array
```javascript=
import React, { useState, useEffect } from "react";
export default function TripList() {
const [trips, setTrips] = useState([]);
useEffect(() => {
// fetch json data
fetch("http://localhost:3000/trips")
.then((res) => res.json())
.then((data) => setTrips(data));
}, []);
return (
<div>
<h2>Trip List</h2>
<ul>
{/* Map to the trip list */}
{trips.map((trip) => (
<li key={trip.id}>
<h3>{trip.title}</h3>
<p>{trip.price}</p>
</li>
))}
</ul>
</div>
);
}
```

---
### useEffect + 動態 url
- 上面講到,如果情境是需要在一開始 fetch data 一次取得資料,沒有其他任何需求,可以將 dependency array 設定為空陣列,但如果我們需要 fetch 的資料有動態的需求呢?假設會因為 query 的字不一樣,需要列出不一樣的結果
- 來建立一個可以 filter 不一樣地點的功能
```javascript=
// TripList.js
// 建立 useState
const [url, setUrl] = useState("http://localhost:3000/trips");
// UseEffect
useEffect(() => {
// fetch json data
fetch(url)
.then((res) => res.json())
.then((data) => setTrips(data));
}, [url]);
//建立兩個按鈕,一個取出歐洲景點的值,一個取出全部的值
<div className="filters">
<button
onClick={() => setUrl("http://localhost:3000/trips?location=europe")}
>
European Trips
</button>
<button onClick={() => setUrl("http://localhost:3000/trips")}>
All Trips
</button>
</div>
```
- 當點擊 European trip 時,會將地點是歐洲的渲染出來

- 點擊 All trips 的時候,渲染出全部景點

> 這裡的順序是:
> 網頁載入
--> fetch(url)
--> evoked useEffect()
--> all dates store in useState
--> rendering
> 點擊 European trip btn
--> reevaluated the url from default url to new one
--> evoked useEffect() by passing the new url
--> get datas that match the query
--> change the useState from old array to new one
--> rendering
---
#### React 18 更新修正
- 上述的程式碼雖然可以運作,但 console 總是會出現錯誤訊息

- 主要是因為 index.js 的 react-dom 已經不支援了,要求使用 18 的版本,可以參考[官方文件](https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#updates-to-client-rendering-apis)
```javascript=
// Before
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
);
// After
import React from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
const container = document.getElementById("root");
const root = createRoot(container);
root.render(<App tab="home" />);
```
---
### useCallback for function dependencies
**重點:如果使用參考資料型態,如函式、物件或者陣列,作為相依性,React 會將這些相依性在每一次元件的重新載入後,視為是有所改變的**
- 試著將 fetch 獨立出來寫在 useEffect 外面
```javascript=
// Trip.js
const fetchTrips = async () => {
const res = await fetch(url);
const json = await res.json();
setTrips(json);
};
useEffect(() => {
fetchTrips();
}, [url]);
```
- 終端機出現警告

- 試著修復,將 fetchTrips 加入到 useEffect 的 相依性中
```javascript=
// Trip.js
const fetchTrips = async () => {
const res = await fetch(url);
const json = await res.json();
setTrips(json);
};
useEffect(() => {
fetchTrips();
}, [url, fetchTrips]);
```
- 出現更可怕的無限 loop

#### **為什麼會發生這樣的事情呢?**
先釐清下過程:
1. 當元件第一次被載入時,useEffect 被觸發,然後接著觸發了裡面的 fetchTrips() 函式,也更新的 states,當 states 更新,元件就會在重新執行
2. 當元件因為 state 更新而再次重新執行,就又會觸發 useEffect,以及裡面的 fetchTrips()
**思考:url 沒有變,fetchTrip()產生的結果也沒有變,為什麼會一直無限觸發呢?**
**這邊要回顧一個重要基礎概念:物件傳參考的特性**
- call by value :6 種原始資料型態
- number
- string
- boolean
- null
- undefined
- symbol
- call by reference: object
- function
- array
- object
- 因為傳參考的特性,因此在重新評估時,fetchTrips()又被觸發,即使這些函式長一樣,產生的結果一樣,但對於電腦來說是在**不同記憶體參考位置**的函式
- 結論,如果 useEffect 有相依陣列、函式或物件,都會有傳參考的特性,但後兩者可以透過 useState hook 包覆,但函式得要用其他方式處理
舉例:這兩個函式是一樣的,但對電腦來說卻都為他們自己建立一個記憶體空間
```javascript=
var dog1 = func(){console.log('14/10')};
var dog2 = func(){console.log('14/10')};
dog1 == dog2; // false
dog1 === dog2; // false
```
- 要怎麼解決這個問題呢?**透過 useCallback 來包覆**
```javascript=
// Trips.js
const fetchTrips = useCallback(async () => {
const res = await fetch(url);
const json = await res.json();
setTrips(json);
}, [url]);
useEffect(() => {
fetchTrips();
}, [fetchTrips]);
```
- useCallback 這邊用來記住 fetchTrips()的 instance,等於回傳一個 `useMemo`,可以想像成第一次被觸發時會先記住結果,然後 useCallback 也需要第二個參數,我們將 動態 url 放進去作為相依性,當我們重新 fetch data 時,當 url 改變時,callback 就會觸發一個新函式,所以我們不需要把 url 作為相依性放在 useEffect 內
> 我的理解是當元件第一次被 render 時,useEffect 內的 fetchTrips()被觸發,然後 useCallback 可以將 fetchTrips()函式暫時保存起來,只有當 url(相依性) 改變時,回傳的函式才會不同,如此一來就可以避免因為 functional component 重新執行後,再次被呼叫。
> 參考資料:[在 useEffect 中使用呼叫需被覆用的函式 - useCallback 的使用](https://ithelp.ithome.com.tw/articles/10225504)
---
### Custome fetch hook
- 在元件上直接寫 fetch data 不易於未來維護的方便性,有可能其他元件需要 fetch,這樣會重覆撰寫程式碼,因此最好的方式是自己建立客製化的 hook
- 首先,先在 src 資料夾內建立一個 hook 的資料夾,接著我們新增一個叫 useFetch 的檔案 **客製化 hook 必須使用 use 開頭**
```javascript=
// useFetch.js
// import 必要的模組
import { useState, useEffect } from "react";
// 建立 useFetch hook
// 把 link 做為參數
const useFetch = (url) => {
// 建立 state 來讓 react 知道資料(狀態)的改變
// 建立 data 變數,預設值為 null
// 建立 修改變數的 setData 方法
const [data, setData] = useState(null)
useEffect(() => {
// 建立 fetch data 的非同步函式
const fetchData = async() => {
const res = await fetch(url);
const json = res.json();
setData(json)
}
fetchData()
}, [url]);
//return {data:data},因為名稱一樣,因此可以簡寫為
return {data}
};
export default useFetch;
```
- 問題:剛剛使用 useCallback 是不是也可以使用這種方式,直接在 useEffect 裡面寫非同步的函式?
> 如果狀況許可,透過直接在 useEffect 裡面把非同步的函式包進去會比較方便,但若情況不允許,為了不產生無限循環的結果,可使用 useCallback 來避免
- import useFetch 到 Trips.js 元件
```javascript=
// Trips.js
// Before
import React, { useState, useEffect, useCallback } from "react";
import "./TripList.css";
export default function TripList() {
const [trips, setTrips] = useState([]);
const [url, setUrl] = useState("http://localhost:3000/trips");
const fetchTrips = useCallback(async () => {
const res = await fetch(url);
const json = await res.json();
setTrips(json);
}, [url]);
useEffect(() => {
fetchTrips();
}, [fetchTrips]);
return (
<div className="trip-list">
<h2>Trip List</h2>
<ul>
{/* Map to the trip list */}
{trips.map((trip) => (
<li key={trip.id}>
<h3>{trip.title}</h3>
<p>{trip.price}</p>
<p>{trip.location}</p>
</li>
))}
...以下省略
// After
import React, { useState, useEffect, useCallback } from "react";
// import useFetch hook
import {useFetch} from "../hook/useFetch"
import "./TripList.css";
export default function TripList() {
const [url, setUrl] = useState("http://localhost:3000/trips");
const {data : trips} = useFetch(url)
return (
<div className="trip-list">
<h2>Trip List</h2>
<ul>
// 這邊直接使用 map 是沒有用的,因為第一次執行時資料是 null
// 使用條件式來表述,如果 trips === true then map through it
{trips && trips.map((trip) => (
<li key={trip.id}>
<h3>{trip.title}</h3>
<p>{trip.price}</p>
<p>{trip.location}</p>
</li>
))}
...以下省略
```
- 執行後發現有錯誤產生如下

上面發生原因是我們在 import useFetch 時發生一些問題,細找之後發現,在 `useFetch.js` 檔案裡面,我們在最後是寫 `export default useFetch`,如果是 `default`,那在 `Trips.js` 引入時,是不需要加入 {},有兩種修復方式:
1. 去掉 {}
2. 修正 `useFetch.js` 的程式碼如下,然後 `Trips.js` 的引入方式不變,維持 `import {useFetch} from "../hook/useFetch"`
```javascript=
// useFetch.js
// Before
const useFetch = (url) => {
const [data, setData] = useState(null)
useEffect(() => {
const fetchData = async() => {
const res = await fetch(url);
const json = res.json();
setData(json)
}
fetchData()
}, [url]);
//return {data:data},因為名稱一樣,因此可以簡寫為
return {data}
};
export default useFetch;
///////////////////////////////////////////////////////
// After
export const useFetch = (url) => {=
const [data, setData] = useState(null)
useEffect(() => {
const fetchData = async() => {
const res = await fetch(url);
const json = res.json();
setData(json)
}
fetchData()
}, [url]);
//return {data:data},因為名稱一樣,因此可以簡寫為
return {data}
};
```
---
### 建立一個 loading / pending 的提示
```javascript=
// useFetch.js
export const useFetch = (url) => {
const [data, setData] = useState(null);
// 加入一個 pending 的資料狀態,並設定為 false
const [isPending, setIsPending] = useState(false);
useEffect(() => {
const fetchData = async () => {
// fetch data 前我們將 isPending 的資料狀態設定為 true
setIsPending(true);
const res = await fetch(url);
const json = await res.json();
// resolve 完獲得資料後,將狀態改為 false
setIsPending(false);
setData(json);
};
fetchData();
}, [url]);
return { data, isPending };
};
```
```javascript=
// TripsList.js
export default function TripList() {
const [url, setUrl] = useState("http://localhost:3000/trips");
const { data: trips, isPending } = useFetch(url);
return (
<div className="trip-list">
<h2>Trip List</h2>
// 使用 && 表示如果 isPending is true 就顯示 Loading trips...
{isPending && <div>Loading trips...</div>}
... 以下省略
```

---
### Error handling
- 如果 url 是錯誤的,我們必須使用 `try...catch...` 來顯示錯誤資訊,而非讓網頁直接進入錯誤頁面
```javascript=
// useFetch.js
export const useFetch = (url) => {
const [data, setData] = useState(null);
const [isPending, setIsPending] = useState(false);
// 建立一個處理錯誤的 state
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
setIsPending(true);
// 進入 try...catch
try {
const res = await fetch(url);
const json = await res.json();
setIsPending(false);
setData(json);
} catch (err) {
// 因為錯誤,因此 pending 的狀態也是 false(沒有 pending/loading)
setIsPending(false);
// 建立錯誤訊息
setError("Could not fetch the data");
console.log(err.message);
}
};
fetchData();
}, [url]);
// return {data : data}
return { data, isPending, error };
};
```
```javascript=
// TripList.js
// 加入 error 的提示
...以上省略
const { data: trips, isPending, error } = useFetch(url);
...中間省略
{error && <div>{error}</div>}
...以下省略
```
- 第一次執行卻發生一堆紅字錯誤,**已經使用 `try...catch...` 為什麼直接給錯誤而非顯示"Could not fetch the data"?**

- 原因在於即使 url 是錯誤的,依舊會得到一個 response,我們在程式碼加入一行 `console.log(res)` 來看看,可以看到瀏覽器顯示一個 response,點開來看有很多資訊,其中 `ok:false`、`status: 404`、`statusText: Not found`,這個錯誤來自於 component 本身,那要怎麼做才能讓我們自己設定的 `try...catch...` 發揮效果呢?

- 透過 `ok:false`,我們可以在 `const res = await fetch(url)` 底下建立一個條件式來判斷 response 是有效還無效
```javascript=
// useFetch.js
try {
const res = await fetch(url);
if (!res.ok) {
throw new Error(res.statusText)
}
const json = await res.json();
setIsPending(false);
setData(json);
// 如果有 fetch 到資料,錯誤狀態就回歸到 null
setError(null)
} catch (err) {
setIsPending(false);
setError("Could not fetch the data");
console.log(err.message);
}
```
- 以上`throw new Error(res.statusText)` 會觸發下方的 `catch(err)`
- `console.log(err.message)` = `res.statusText` = Not found
- `setError("Could not fetch the data");` 會 render 到 `TripList.js` 裡面我們稍早建立的錯誤訊息提示 `{error && <div>{error}</div>}`

- 因為 European Trips 和 All Trips 的按鈕所觸發的 url 是正確的,因此一旦我們點擊了,就會把 url 的 state 狀態重新執行一次,而因為 url 是正確的,所以 `setError(null)` 錯誤提示就會消失,然後 List 會重新被渲染出來

---
### Cleanup function
- 假設情境:增加一個 hide trip 的按鈕,並隱藏所有的 list,如果我們在非同步函式還在 fetch data 的時候,就先點擊了 hide 按鈕,會發生什麼事情呢?
```javascript=
// App.js
import { useState } from "react";
import "./App.css";
import TripList from "./components/TripList";
export default function App() {
// 建立顯示 Trips 的資料狀態
const [showTrips, setShowTrips] = useState(true);
return (
<div className="App">
// 增加點擊後隱藏 Trips 的按鈕
<button onClick={() => setShowTrips(false)}>Hide trips</button>
{showTrips && <TripList />}
</div>
);
}
```

- 上述錯誤訊息表示無法將 react 的資料更新到一個已經卸載的元件上,因為還在 fetch data 的時候,我們就點擊 hide 按鈕隱藏 List,但 fetch 的動作還在背景執行,因此我們需要一個中止 fetch 動作的 cleanup function
```javascript=
// useFetch.js
useEffect(() => {
// 建立一控制器
const controller = new AbortController();
const fetchData = async () => {
setIsPending(true);
try {
// fetch 接受兩個參數,第一個為網址,第二個為物件(option parameter),例如 POST,GET, HEADER 等
// 以及 signal: controller.signal
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(res.statusText);
}
const json = await res.json();
setIsPending(false);
setData(json);
setError(null);
} catch (err) {
// 如果中止的錯誤原因是 AbortError 就輸出 The fetch was aborted
if (err.name === "AbortError") {
console.log("The fetch was aborted");
} else {
setIsPending(false);
setError("Could not fetch the data");
console.log(err.message);
}
}
};
fetchData();
// 回傳並觸發中止
return () => {
controller.abort();
};
}, [url]);
```

>參考資料:[Fetch:中止(Abort)](https://zh.javascript.info/fetch-abort)