React 學習筆記(2) - useEffect, useCallback, fetch data === ![](https://i.imgur.com/ZLirEbH.png) ###### 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://i.imgur.com/ATglsNg.png) - 接著打開 `https://localhost:3000/trips` 就可以看見我們的 json 文件 ![](https://i.imgur.com/WuKeMRF.png) - 接著打開 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 另外建立一個埠號即可 ![](https://i.imgur.com/7hmZ0ki.png) - 開啟 React app 的本地端伺服器後,打開 develop tool 的 console,會看見我們稍早前 console.log(data) ![](https://i.imgur.com/lGmdN8q.png) --- ### 使用 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,因為並沒有任何一段程式碼阻斷,所以就不斷輸出結果。** ![](https://i.imgur.com/7QdN159.png) --- ### 使用 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 ![](https://i.imgur.com/ojjI87k.png) - 如果把第二個參數空陣列拿掉,一樣會出現無限增生的 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> ); } ``` ![](https://i.imgur.com/Tj8ItFE.png) --- ### 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 時,會將地點是歐洲的渲染出來 ![](https://i.imgur.com/QOlPvZC.png) - 點擊 All trips 的時候,渲染出全部景點 ![](https://i.imgur.com/XOnXlpI.png) > 這裡的順序是: > 網頁載入 --> 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 總是會出現錯誤訊息 ![](https://i.imgur.com/zcH6qzn.png) - 主要是因為 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]); ``` - 終端機出現警告 ![](https://i.imgur.com/IeV7Uhy.png) - 試著修復,將 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 ![](https://i.imgur.com/ClmjBjy.png) #### **為什麼會發生這樣的事情呢?** 先釐清下過程: 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> ))} ...以下省略 ``` - 執行後發現有錯誤產生如下 ![](https://i.imgur.com/fPuRDXG.png) 上面發生原因是我們在 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>} ... 以下省略 ``` ![](https://i.imgur.com/qhtY8Hs.png) --- ### 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"?** ![](https://i.imgur.com/n90ApU8.png) - 原因在於即使 url 是錯誤的,依舊會得到一個 response,我們在程式碼加入一行 `console.log(res)` 來看看,可以看到瀏覽器顯示一個 response,點開來看有很多資訊,其中 `ok:false`、`status: 404`、`statusText: Not found`,這個錯誤來自於 component 本身,那要怎麼做才能讓我們自己設定的 `try...catch...` 發揮效果呢? ![](https://i.imgur.com/uDkrgHm.png) - 透過 `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>}` ![](https://i.imgur.com/wDM0jIR.png) - 因為 European Trips 和 All Trips 的按鈕所觸發的 url 是正確的,因此一旦我們點擊了,就會把 url 的 state 狀態重新執行一次,而因為 url 是正確的,所以 `setError(null)` 錯誤提示就會消失,然後 List 會重新被渲染出來 ![](https://i.imgur.com/CBt6IUv.png) --- ### 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> ); } ``` ![](https://i.imgur.com/yXLFMnN.png) - 上述錯誤訊息表示無法將 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]); ``` ![](https://i.imgur.com/LipuC6E.png) >參考資料:[Fetch:中止(Abort)](https://zh.javascript.info/fetch-abort)