React useEffect Hook 抓取 API 資料

2024/01/17閱讀時間約 12 分鐘

在實作 Movie Guide 專案時,遇到了要從 OMDB API 抓取電影資料的需求。以往用 Vanilla JavaScript 時,馬上就會想到使用原生的 fetch 方法,大不了導入 Axios 套件。

以上都能在 React 中實現,但由於框架的特性,我們需要使用 useEffect Hook 來抓取第三方資料。這篇文章會記錄首次嘗試的踩雷以及 useEffect 的正確使用方式。


💣 錯誤的抓資料方式

一開始我們直接把 fetch 赤裸裸地寫在 App 根元件裡面,先把 interstellar 的搜尋結果列印出來:

export default function App() {
...
const query = "interstellar";

fetch(`http://www.omdbapi.com/?apikey=${KEY}&s=${query}`)
.then((res) => res.json())   
.then((data) => console.log(data));
}


打開應用程式檢查,console 確實列印出了搜尋結果:

會跑出兩個重複的 Object,是因為採用 React StrictMode

會跑出兩個重複的 Object,是因為採用 React StrictMode


但這樣做其實犯了大忌:❌在渲染邏輯當中產生 side effect

所謂的 side effect 是指我們在元件內和外部資料溝通,而渲染邏輯,則是每當 App 元件 mount 的時候,都會去執行 fetch

如果我們結合 setState 來看會更加清楚發生什麼問題:

export default function App() {
...
const [movies, setMovies] = useState([]);
const query = "interstellar";

fetch(`http://www.omdbapi.com/?apikey=${KEY}&s=${query}`)
.then((res) => res.json())   
.then((data) => setMovies(data.Search)); // 使用 setState
}


打開開發者工具的 Network,應用程式竟然每一秒都持續發出 request!

左下角的 request 次數持續暴增

左下角的 request 次數持續暴增


這是因為在 React 中,state 只要更新就會造成相關元件重新渲染 (React 重新呼叫元件函式)。而重新渲染又會再次執行 fetch,進而呼叫 setMovie 造成 state 更新,周而復始進入無限迴圈。

但我們需要在 fetch 資料後去更新 state 啊,到底該怎麼辦?

這時就需要靠 useEffect Hook 了!


使用 useEffect Hook

什麼是 side effect?

useEffect 能幫助我們妥善管理 side effect。

由於 React 提倡可預期結果的 pure function,因此對於 side effect 管理有所著墨。前面有提過,和外部資料溝通便是典型的 side effect。我們不確定 api 伺服器什麼時候會突然故障,或是資料傳遞出現問題,這些全是不可預期的,有違 pure function 可預期結果的準則。

一些常見的 side effect 如下:

  • 向後端伺服器要求資料傳遞
  • 和瀏覽器 API 溝通,像是 windowdocument
  • 使用計時相關的函式,例如 setTimeoutsetInterval

💡 這邊再次強調,我們並非要完全阻絕 side effect 發生,而是要讓它們在正確的時間,以正確的形式運作,就和告白一樣

在前面的例子中也看到了,若把 side effect 放在渲染邏輯中,很容易出現非預期的問題。換句話說:

side effect 最好在元件渲染之後才執行完成。

總結來說,useEffect 確保我們和外部世界互動時 (side effect),不會影響到元件渲染執行。

useEffect 的基本語法

我們要帶入的參數有兩個:

  • callback 函式:渲染完畢後要執行的回呼函式
  • dependency 陣列:siede effect 所依賴的所有值

React 會確認 dependency 陣列中的值是否在渲染之間變更,如果有的話,將重新執行 callback 函式。由於上述範例中,我們是希望 fetch 僅在首次渲染,也就是元件 mount 時執行,因此帶入空陣列。

import { useEffect } from 'react';

export default function App() {
...
const [movies, setMovies] = useState([]);
const query = "interstellar";

​useEffect(() => {
fetch(`http://www.omdbapi.com/?apikey=${KEY}&s=${query}`) // 第一個參數帶入 callback
.then((res) => res.json())   
.then((data) => setMovies(data.Search));
}, []) // 第二個參數帶入空陣列

}


useEffect 與 async...await

現在我們嘗試將 promise 的寫法改成 async...await 看看:

import { useEffect } from 'react';

export default function App() {
...
const [movies, setMovies] = useState([]);
const query = "interstellar";

​useEffect(async () => { // ❌
fetch(`http://www.omdbapi.com/?apikey=${KEY}&s=${query}`)
.then((res) => res.json())   
.then((data) => setMovies(data.Search));
}, [])

}


結果跑出以下錯誤訊息:

Effect callbacks are synchronous to prevent race conditions. Put the async function inside: ....

問題在於 useEffect 裡面的 callback 函式不能回傳 promise,而 async...await 本身只是 promise 的語法糖,才會出現錯誤。

解決方式很直觀,我們可以將 async 包覆在另一個函式裡面,不要直接在 useEffect 呼叫它:

  // Fetch movie data
useEffect(() => {
const fetchMovies = async () => {
const response = await fetch(
`http://www.omdbapi.com/?apikey=${KEY}&s=${query}`
);
const data = await response.json();
setMovies(data.Search);
};
fetchMovies();
}, []);

Dependency 陣列

useEffect 預設上會在每次渲染後運作,如果不希望這樣的行為,就需要提供 dependency 陣列。少了此陣列,React 將不知道何時該執行 effect 函式 (第一個參數的 callback 函式)。

提供陣列值後,每當 dependency 更動,effect 函式便會重新運作。舉例來說:

import { useEffect } from 'react';

function User({ name }) {
useEffect(() => {
document.title = name;
}, [name]);

return <h1>{name}</h1>;
}


由於更動瀏覽器 API 的 document,所以需要透過 useEffet 來處理 side effect。這邊第二個參數帶入了 dependency 陣列,值為 name。 每當這個外部值有所更新,useEffect 的 effect 函式便會重新執行,確保 document.title 維持最新的名稱值。

在這層涵義上,useEffect 其實與事件監聽有異曲同工之妙,然而兩者之間的差別在於,事件監聽是監聽使用者事件 (滑鼠點擊、鍵入......),至於 useEffect 則是監聽 dependency 的改變。所以 dependency 陣列非常重要。


Cleanup 函式

無論是職場或日常生活,我們都很忌諱拉完屎就跑的人。同理,在使用 useEffect 時,當 side effect 於元件 unmounted 或重新渲染後,記得要透過 cleanup 函式清除上一回的 side effect

如果不清除會怎麼樣?

不清除 side effect 的話,可能會發生 race condition。道理和使用 setInterval() 類似,我們需要透過 clearInterval() 來取消 interval,否則裡面的 callback 函式會一直持續下去。

而這也是為什麼保持一個 useEffect 處理一個 side effect,以便後續清除順暢。

所以雖然 cleanup 函式不是 useEffect 必要的參數,在以下兩種狀況還是乖乖使用比較好:

  1. 在 effect 函式再次啟動之前
  2. 在元件 unmount 之後

cleanup 函式的使用方式,是在 useEffect 裡面回傳它,以下便以 setInterval() 舉例:

function Timer() {
const [time, setTime] = useState(0);

useEffect(() => {
let interval = setInterval(() => {
setTime((prevTime) => prevTime + 1)
}, 1000);

👉 return () => {
// setInterval cleared when component unmounts
clearInterval(interval);
}
}, []);
}


Timer 元件 unmount, cleanup 函式,也就是 clearInterval() 將被呼叫。如果沒有執行清除,就算元件 unmount 了,setInterval() 仍會繼續嘗試去更新早已不存在的 time state,造成記憶體外洩,聽起來非常悲哀。


參考資料

16會員
34內容數
Bonjour à tous,我本身是法文系畢業,這邊會刊登純文組學習網頁開發的筆記。如果能鼓勵更多文組夥伴一起學習,那就太開心了~
留言0
查看全部
發表第一個留言支持創作者!