跳至主要内容

[ReactDoc] React Hooks - useEffect

本文章內容來自:

useEffect 的使用時機類似在傳統 class 中使用 componentDidMount, componentDidUpdatecomponentWillUnmount 的生命週期,關於 useEffect 的基本說明可以參考「React Hooks 起步走」。

Dan AbramovA Complete Guide to useEffect 中提到,在學習 useEffect 時,我們應該忘記過去對於 React 生命週期所學到的,以全新的體驗和思考方式來認識 useEffect

筆記

  • 使用 useEffect 一定要留意第一次執行的時間點,如果第一次不想要執行要 return 掉
  • 使用 useEffect 一定要留意 dependencies 陣列中的變數,它們的改變會觸使 useEffect 執行
  • dependencies array 是透過 Object.is() 來比較兩個值是否相等
// 第一次資料進來時 state 可能是 undefined(例如 AJAX),此時不要執行
useEffect(() => {
if (hasScreenPermission === undefined) return;
// 第二次拿到 state 後才執行...
setState({
error: 'foobar',
});
}, [hasScreenPermission, setState]);

留意重點

  • 簡單來說,useEffect 內的 effect 會在每一次 DOM 轉譯後被呼叫(不論是 mounted 或 updated)如果有回傳 cleanup function 的話,則會在每一次 DOM 轉譯後的最開始先被呼叫,接著才執行該次的 effect元件 unmount 或每次 updated 前被呼叫)
  • 實際上在每一次元件轉譯時 useEffect 內的函式都是新的、不同的,並會把舊的給覆蓋;換句話說,effect 會更像是隸屬於每次轉譯的一部分
  • 由於這些 effects 會在每次元件轉譯時都執行(而非只執行一次),因此實際上 useEffect 中回傳的 cleanup 函式並非只有在元件 unmount 時被呼叫到,而是在每次執行該 effect 前也會呼叫一次 clean up 的函式
  • useState 一樣,在一個元件中可以多次使用 useEffect,因此你可以根據程式邏輯拆成許多不同的 effects。

Hooks 基本使用

在 React 元件中有兩種常見的副作用(side effects),一種需要被清理(cleanup),一種則不用。

不需要被清理的作用(Effects Without Cleanup)

有時我們會需要在 React 更新完 DOM 之後來執行其他的程式碼,像是網路請求(Network request)、手動操作 DOM(manual DOM mutation)或紀錄(logging),這些都是常見不需要被清理的作用,也就是做完就不用再去管它。

透過使用 useEffect 這個 Hook,你將可以在元件轉譯完成去做一些事,React 會記得你傳入 useEffect 內的 function(這個 function 我們稱做 effect),並在執行完 DOM 的更新後呼叫它。

預設的情況下這些 effect 會在元件第一次被轉譯以及後續每一次更新時被呼叫到(除非去自訂這樣的行為),因此,現在不必再去想這個方法需要在 mounting 還是 updating 時被呼叫,只要知道這些 effect 會在元件「轉譯後(after render)」被呼叫,React 會確保 DOM 已經更新完成後才去呼叫這個 effect。

componentDidMountcomponentDidUpdate 不同的地方在於,在 useEffect 中的 effects 並不會使得瀏覽器阻塞而無法更新畫面,大部分的 effects 並不需要同步(synchronously)處理,少部分除了像是要測量 layout 這種 effect,則會使用 useLayoutEffect Hook。

範例程式碼

// https://reactjs.org/docs/hooks-effect.html#example-using-hooks
import React, { useState, useEffect } from 'react';

function Example() {
const [count, setCount] = useState(0);

useEffect(() => {
document.title = `You clicked ${count} times`;
});

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}

需要被清理的作用(Effects with Cleanup)

有些時候我們需要訂閱(subscription)一些外部資料,在這情況下「清理(cleanup)」就顯得很重要,如此才可以避免記憶體洩漏(memory leak)的問題。

在傳統 React class 的寫法中,我們會在 componentDidMount 時去註冊監聽某事件,在 componentWillUnmount 時去移除監聽事件的註冊。

useEffect 中,則可以回傳一個清理用的函式(可以是匿名函式),它會在 React 元件要 unmount 時被執行,此外,由於每次元件轉譯時,執行的都是一個全新的 effect,因此在每此元件轉譯前也都會執行 cleanup 的函式。也就是說,cleanup 函式會在「每次元件轉譯前」,以及「元件 unmount」時被執行

Explanation: Why Effects Run on Each Update @ React Docs

imgur

範例程式碼

// https://reactjs.org/docs/hooks-effect.html#example-using-hooks-1
import React, { useState, useEffect } from 'react';

function FriendStatus(props) {
const [isOnline, setIsOnline] = useState(null);

useEffect(() => {
// 事件處理器(event handler)
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

// 註冊事件
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);

// 定義清理的方法並回傳
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});

if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}

非同步請求資料時(async to fetch data)

要特別注意的是,useEffect 的回傳值只能是空或者是清理函式(cleanup function),因此 callback 並不能帶入 async function,因為 async function 實際上是回傳一個 implicit promise。所以如果需要使用非同步存取資料時,需要將 async function 獨立出來放在 useEffect 內,或進一步參考後面的[ 式](#定義在 function component 內使用的函式):

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

useEffect(() => {
async function fetchData() {
// You can await here
const response = await MyAPI.getData(someId);
// ...
}
fetchData();
}, [someId]); // Or [] if effect doesn't

範例程式碼

/**
* https://www.robinwieruch.de/react-hooks-fetch-data
* fetch data with constant
* fetch data with variable
* fetch data when use click button
* fetch data with loading indicator
* fetch data with error handling
* see diff on gist
* https://gist.github.com/PJCHENder/54108cffe99d6e94f99a8e3577ed3d3e/revisions
*/

import React, { useState, useEffect } from 'react';
import axios from 'axios';

function DemoOfFetchData() {
const [data, setData] = useState([]);
const [query, setQuery] = useState('');
const [queryString, setQueryString] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);

useEffect(() => {
const fetchData = async () => {
setIsError(false);
setIsLoading(true);

try {
const result = await axios(`https://hn.algolia.com/api/v1/search?query=${queryString}`);
setData(result.data && result.data.hits.filter((item) => item.title));
} catch (error) {
setIsError(true);
}
setIsLoading(false);
};
fetchData();
}, [queryString]);

return (
<div style={{ padding: 60 }}>
<input type="text" value={query} onChange={(e) => setQuery(e.target.value)} />
<button type="button" onClick={() => setQueryString(`query=${query}`)}>
Search
</button>

{isError && <div>Something went wrong...</div>}

{isLoading ? (
<div>Loading ...</div>
) : (
<ul>
{data.map((item) => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
)}
</div>
);
}

export default DemoOfFetchData;

使用相依陣列(dependency array):略過某些 effect 以提升效能

keywords: 相依陣列, dependency array, deps

在過去 React class 中,會在 componentDidUpdate 內使用一些判斷式避免在不必要的元件更新,例如說只有在 prevState 和當前 this.state 的狀態不同時,才會更新:

componentDidUpdate(prevProps, prevState) {
if (prevState.count !== this.state.count) {
document.title = `You clicked ${this.state.count} times`;
}
}

帶入相依陣列(dependency array):effect 只會在陣列內容變更時被執行

useEffect 中可以做到類似的效果,當某些值沒有改變的時候,你可以叫 React 在重新轉譯時(re-renders / update)跳過某些 effect,只需要在 useEffect 的第二個參數中帶入「相依陣列(dependency array, deps)」即可,只要陣列中的元素沒有改變時,React 就會跳過該 effect,但只要陣列中有任何一個值不同,就不會跳過 effect

useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // 只會在 count 有改變時重新執行該 effect

⚠️ Best Practice: 最好的方式就是把所有在 useEffect 內有使用到的 stateprops 都定義在相依陣列中。

帶入空陣列:將相依陣列帶入空陣列(小心使用!)

如果該 effect 內沒有使用到任何 states 或 props,而你希望這個 effect 和 cleanup 只會被執行一次(只有在 mount 和 unmount),那個第二個參數可以帶入一個空陣列([]),這會告訴 React 這個效果不會依賴到任何 props 或 state,因此它完全不需要重新執行

❗ 只有在該 useEffect 內沒有使用到的任何 props 或 states 的情況下可以帶入空陣列 []

// 若在 effect 內沒有使用到任何相依的 states 或 props
// 可以在 `useEffect` 的第二個參數帶入 []
// 如此 updated 時都不會被呼叫到
useEffect(() => {
// 只會在 mounted 時執行一次 effect
console.log('--- useEffect: no dependent on states or props ---');
return () => {
// 只會在 unmount 時執行一次 cleanup
console.log('--- cleanup useEffect: no dependent on states or props ---');
};
}, []);

若沒有在第二個參數中帶入 [],則該函式會每次重新轉譯時(updated)都呼叫到。

💡 提示:當相依陣列裡放入的是 dispatch, setState, useRef 可以省略,因為 React 會確保它們是靜態的。

定義在 function component 內使用的函式

函式相依於 state 或 prop

方法一:若該函式不會再被重複使用-將函式放入 effect 內

如果在 useEffect 中呼叫的函式相依於該元件的 states 或 props 時,為了避免忘記把函式中使用到的 states 或 props 放到相依陣列(dependency array)中,建議「若該函式只用在該 useEffect 內使用到的話,應該把該函式也一併放入 useEffect」:

// ❌ 錯誤寫法!!
function ProductPage({ productId }) {
const [product, setProduct] = useState(null);

async function fetchProduct() {
const response = await fetch('http://myapi/product' + productId); // Uses productId prop
const json = await response.json();
setProduct(json);
}

useEffect(() => {
fetchProduct();
}, []); // 🔴 在 `fetchProduct` 使用到了 props `productId`,但沒有定義在相依陣列中
// ...
}

建議把有相依到 states 或 props 的函式放到 useEffect 中:

// 👍 正確寫法:把有相依到的 states 或 props 的函式一併放到 useEffect 內
function ProductPage({ productId }) {
const [product, setProduct] = useState(null);

useEffect(() => {
// 將 function 搬到 effect 內,可以清楚定義這個函式內用到了哪些 state 或 prop
async function fetchProduct() {
const response = await fetch('http://myapi/product' + productId);
const json = await response.json();
setProduct(json);
}

fetchProduct();
}, [productId]); // ✅ 這是有效的,因為在這個 effect 中我們只有相依用到 productId 這個 prop
// ...
}

方法二:若函式會被重複使用 - 搭配 useCallback

query 這個 state 帶入到 useCallback 中,並且加入到 useCallback 的 dependency。這個時候只要 query 沒有改變,getFetchUrl 就不會改變,如此 useEffect 便不會重新執行:

// https://overreacted.io/a-complete-guide-to-useeffect/#but-i-cant-put-this-function-inside-an-effect

import React, { useState, useEffect, useCallback } from 'react';

const FetchData = () => {
const [query, setQuery] = useState('');

const getFetchUrl = useCallback(() => {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, [query]);

useEffect(() => {
const url = getFetchUrl();
// 對 url 做某些事...
}, [getFetchUrl]);

return (
<div>
<h1>Fetch Data</h1>
<input type="text" onChange={(e) => setQuery(e.target.value)} />
</div>
);
};

export default FetchData;

imgur

useCallback 回傳的函式一樣可以當成 props 傳給子元件:

// https://overreacted.io/a-complete-guide-to-useeffect/#but-i-cant-put-this-function-inside-an-effect
function Parent() {
const [query, setQuery] = useState('react');

// ✅ Preserves identity until query changes
const fetchData = useCallback(() => {
const url = 'https://hn.algolia.com/api/v1/search?query=' + query;
// ... Fetch data and return it ...
}, [query]); // ✅ Callback deps are OK

return <Child fetchData={fetchData} />;
}

function Child({ fetchData }) {
let [data, setData] = useState(null);

useEffect(() => {
fetchData().then(setData);
}, [fetchData]); // ✅ Effect deps are OK

// ...
}

函式不依賴 state 或 prop

A Complete Guide to useEffect - But I Can’t Put This Function Inside an Effect @ Overreacted

有些時候,我們會需要在多個 useEffect 中呼叫同一個函式,但我們又不希望把同樣的函式重複寫在各別的 useEffect 內。若我們是透過把函式放在相依陣列中的話,因為每次 function component 被呼叫時,內部的 function 都會是新的、不一樣的,因此即使將這個函式放到相依陣列中,並沒有任何幫助(更多的說明可以參考 But I Can’t Put This Function Inside an Effect @ Overreacted),像是這樣:

// ❌ 錯誤寫法!!

function SearchResults() {
// 🔴 因為每次轉譯時的 getFetchUrl 都是不同的
function getFetchUrl(query) {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}

useEffect(() => {
const url = getFetchUrl('react');
// ... Fetch data and do something ...
}, [getFetchUrl]); // 🚧 Deps are correct but they change too often

useEffect(() => {
const url = getFetchUrl('redux');
// ... Fetch data and do something ...
}, [getFetchUrl]); // 🚧 Deps are correct but they change too often

// ...
}

方法一:將函式拉到 function component 外

若該函式不依賴於 state 或 prop,最簡單的作法就是把它拉到 function component 外,像是這樣:

// 因為 getFetchUrl 這個函式並不需要依賴到 state 或 prop,
// 因此可以拉到 function component 外
function getFetchUrl(query) {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}

function SearchResults() {
useEffect(() => {
const url = getFetchUrl('react');
// ... Fetch data and do something ...
}, []); // ✅ Deps are OK

useEffect(() => {
const url = getFetchUrl('redux');
// ... Fetch data and do something ...
}, []); // ✅ Deps are OK

// ...
}

方法二:搭配 useCallback

或者可以使用 useCallback 的效果,

import React, { useEffect, useCallback } from 'react';

const FetchData = () => {
console.log('invoke function component');

const getFetchUrl = useCallback((query) => {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, []);

useEffect(() => {
const url = getFetchUrl('react');
// 對 url 做某些事...
}, [getFetchUrl]);

useEffect(() => {
const url = getFetchUrl('redux');
// 對 url 做某些事...
}, [getFetchUrl]);

return (
<div>
{console.log('render')}
<h1>Fetch Data</h1>
</div>
);
};

export default FetchData;

使用 useEffect 的訣竅(Tips for Using Effects)

Tips for Using Effects @ React Docs

在元件中使用多次 useEffect

useState 一樣,在一個元件中可以多次使用 useEffect,因此你可以根據程式邏輯拆成許多不同的 effects ,而不再需要根據生命週期。React 會在元件內根據程式碼的順序執行每一個 effect。

範例程式碼

function FriendStatusWithCounter(props) {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});

const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
// ...
}

透過 useReducer 將更新資料的邏輯和描述(action)分開

透過 useReducer 可以將更新資料的邏輯和描述(action)分開來,在更新資料的邏輯中,常常會需要相依到其他的 state 或 props,這使得我們需要將它們定義在相依陣列(dependency array)中;但若我們透過 useReducer 這種方式,可以把更新資料的邏輯從 useEffect 內抽出,放到 useReducer 中,如此,在 useEffect 內只需要透過 dispatch(<action>) 說明要做什麼動作,而更新資料的邏輯和動作則是放在 useReducer 內,這樣就不用把相依到的 state 和 props 放在 useEffect 的相依陣列中。做法可以進一步參考:「useReducer - React Hooks API Reference」@ PJCHENder Notes。

fetchData: 只用最後一次 onchange 的 data

useEffect 的開始都設成 ignore = false,當使用者從輸入框輸入文字時,觸發 onChange,因為 query 改變,所以 useEffect 會重新執行。當使用者輸入 react 時,因為輸入 r, e, a, c 的時候,因為後續的 update 都還有被觸發,因此都會執行到 cleanup 方法,此時 ignore 都會變成 true,因此不會呼叫 setData;但當使用者輸入最後一個字 t 時,因為沒有後續的元件更新,因此該 effect 的 cleanup 方法不會被呼叫到,此時他的 ignore 仍會是 false,進而可以呼叫到 setData 來更新資料:

function SearchResults() {
const [data, setData] = useState({ hits: [] });
const [query, setQuery] = useState('react');

useEffect(() => {
let ignore = false;

async function fetchData() {
const result = await axios('https://hn.algolia.com/api/v1/search?query=' + query);

if (!ignore) setData(result.data);
}

fetchData();
return () => {
console.log('---cleanup---');
ignore = true;
};
}, [query]);

return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{data.hits.map((item) => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
</>
);
}

深入觀念

每一次轉譯都是獨立的作用域,useEffect 亦然

Each render has its own props and state @ Overreacted

每次得到的 props, state 都只屬於該次轉譯,過了就是不同的

在下面這段程式碼中,畫面中的 { count } 是每一次都會透過 useState() 得到的常數,在點擊按鈕後,使用者之所以可以看到畫面更新,不是因為 count 做了什麼資料綁定或代理,純粹只是 React 會重新轉譯該元件,因此每一次轉譯取得的 count 都是獨立的。

每次畫面重新轉譯時都會呼叫 function component,但每一次呼叫該 function component 時,這個 count 都是常數,它只是被設為當前轉譯時的資料狀態。

function Counter() {
const [count, setCount] = useState(0);

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}

每次呼叫的事件處理器(event handlers)都只屬於該次轉譯,過了就是不同的

同樣的,每一次轉譯都有獨立的事件處理器,以下面的程式碼為例,實際上每次畫面轉譯時,都會有獨立的 handleAlertClick() 這個方法,而在呼叫該 function component 時,就把 useState() 回傳的值給包存在該作用域內了:

function Counter() {
const count = 2; // Returned by useState()
// ...
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + count); // count 的值已經被保存下來
}, 3000);
}
// ...
<button onClick={handleAlertClick} />; // The one with 2 inside
// ...
}

放到 useEffect 時亦然

同樣的概念當我們套用到 useEffect 時也是適用的,在 useEffect 中之所以可以取得 ${ count } 的值,不是因為什麼資料綁定或代理,純粹是透過 useState() 取得該次元件轉譯時,count 這個常數的值罷了:

每一個 useEffect 也都只屬於該次元件轉譯(function component 被呼叫)時,過了下次就不同了,就如同 state, props 以及 event handlers 一樣。

function Counter() {
const [count, setCount] = useState(0);

useEffect(() => {
document.title = `You clicked ${count} times`;
});

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}

關於 useEffect 中的清理(cleanup)

Cleanup 函式執行的時間點其實是在「下一次元件轉譯後的開頭」被執行

So What About Cleanup? @ Overreacted

當我們的元件長得像這樣子,並在不同時間點 console 時:

import React, { useState, useEffect } from 'react';

function Counter() {
const [count, setCount] = useState(0);
console.log('Invoke Counter');

useEffect(() => {
document.title = `You clicked ${count} times`;
console.log('useEffect', count);

return () => {
console.log('useEffect cleanup', count);
};
});

return (
<div>
{console.log('render')}
<p>You click {count} times</p>
<button onClick={() => setCount(count + 1)}>Add</button>
</div>
);
}

export default Counter;

以下是 useEffect 執行清理函式(cleanup)的時間:

# 第一次轉譯
Invoke Counter
render
useEffect 0

# 元件更新
Invoke Counter
render
useEffect cleanup 0 // 裡面是上一次 useEffect 取得的 state 和 props
useEffect 1 // 這次 useEffect 取得的 state 和 props

# 元件更新
Invoke Counter
Counter.js:29 render
Counter.js:24 useEffect cleanup 1 // 裡面是上一次 useEffect 取得的 state 和 props
Counter.js:13 useEffect 2 // 這次 useEffect 取得的 state 和 props

# 元件 unmount 時
useEffect cleanup 2

你可能不該使用 Effects

內容整理自 You Might Not Need an Effect @ React Docs beta

不要在 useEffect 中做資料轉換:直接在 component root level 處理資料

不要在 useEffect 中做下面這種資料轉換,會會導致元件每次都有不必要的 re-render:

// Transform data without Effects
// https://beta.reactjs.org/learn/you-might-not-need-an-effect#challenges
// ❌ 避免這樣寫
export default function TodoList() {
const [todos, setTodos] = useState(initialTodos);
const [activeTodos, setActiveTodos] = useState([]);

useEffect(() => {
setActiveTodos(todos.filter(todo => !todo.completed));
}, [todos]);

return (/* ... */)
}

直接在元件的 Root Level 進行資料轉換的處理即可,改成這樣:

// ✅ 建議這樣寫
export default function TodoList() {
const [todos, setTodos] = useState(initialTodos);

// 直接在 component root level 處理資料
const activeTodos = todos.filter(todo => !todo.completed)

return (/* ... */)
}
搭配 useMemo

如果需要的話,可以將計算的結果用 useMemo 保存起來,讓它不用每次都重複計算。一個判斷的方式是使用 console.time('filter array');console.timeEnd('filter array'); 來看運算是否太耗時,再來判斷要不要用 useMemo 包起來。

不要在 useEffect 中重設子元件的資料狀態:善用元件的 key 屬性

為了讓 child component 和 parent component 的狀態一致,這種寫法很常在「表單」或 Modal 元件中看到。

在表單資料處理中,為了讓父層的資料與子層保持一致,但父層資料改變時,很常會在 useEffect 中呼叫類似 resetForm 的方法來同步更新子元件的資料狀態,但這麼做並不理想,放在 useEffect 中執行同樣會使得 React 的元件 re-render,例如:

// Reset state without Effect
// https://beta.reactjs.org/learn/you-might-not-need-an-effect#challenges

// ❌ 避免這樣寫
export default function EditContact({ savedContact, onSave }) {
// 把 props 的資料保存一份在元件的 state 中
const [name, setName] = useState(savedContact.name);
const [email, setEmail] = useState(savedContact.email);

// 如果外層的 props 改變時,連動改變元件中的 state
useEffect(() => {
setName(savedContact.name);
setEmail(savedContact.email);
}, [savedContact]);

return <>{/* ... */}</>;
}

比較建議的寫法,是善用 React 元件的 key 屬性。

一般的情況下,React 會保存同一個元件內部的資料狀態(除非整個 unmounted 後),但當元件的 key 不一樣時,等於是告訴 React 這兩個元件是「不同」的,因此 React 會重新建立這個元件的 DOM 和 state,也就自動達到了「重設資料狀態」的目的。

因此,比較好的作法是拆成兩個元件,並善用 key 來達到元件內資料狀態重設的目的:

// ✅ 比較好的寫法 —— 拆成兩個元件,並善用 key 來達到元件內資料狀態重設的目的

export default function EditContact(props) {
return <EditContactForm key={props.savedContact.id} {...props} />;
}

function EditContactForm({ savedContact, onSave }) {
const [name, setName] = useState(savedContact.name);
const [email, setEmail] = useState(savedContact.email);

return <>{/* ... */}</>;
}

不要在 useEffect 中處理和特定事件直接相關的邏輯:在特定的 event handler 或函式中處理掉即可

舉例來說,當使用者點擊按鈕後,要把商品添加到購物車中,並顯示提示。下面這個是不建議的寫法:

// Sharing logic between event handlers
// https://beta.reactjs.org/learn/you-might-not-need-an-effect#sharing-logic-between-event-handlers

// ❌ 避免這樣寫
export default function ProductPage() {
// 避免將 event handlers 中就該完成的操作額外放到 useEffect 中
useEffect(() => {
if (product.isInCard) {
showToast(`Added ${product.name} to the shopping cart!`);
}
}, [product]);

const handleBuyClick = () => {
addToCart(product);
};

const handleCheckoutClick = () => {
addToCart(product);
redirect('/checkout');
};

return <>{/* ... */}</>;
}

比較好的寫法,應該是在事件或函式中就把發送提示這個操作一併處理掉:

// Sharing logic between event handlers
// https://beta.reactjs.org/learn/you-might-not-need-an-effect#sharing-logic-between-event-handlers

// ✅ 建議這樣寫
export default function ProductPage() {
// 在事件或函式中就把發送提示這個操作一併處理掉
function buyProduct(product) {
addToCart(product);
showToast(`Added ${product.name} to the shopping cart!`);
}

const handleBuyClick = () => {
buyProduct(product);
};

const handleCheckoutClick = () => {
buyProduct(product);
redirect('/checkout');
};

return <>{/* ... */}</>;
}

在 useEffect 中處理 fetching API 的 race condition(dedupe)

我們常會在 useEffect 中使用 API 來拉資料,但卻常忽略了要處理 race condition 的情況,也就是說,如果我們的 API 是根據使用者輸入的內容不斷發送請求,我們無法確保使用者最後一次拿到的資料是最後一次發出請求的資料,因為 API fetching 是非同步的特性,有可能一開始發出的 request 比較晚才回來,而最後發出的請求卻比較早回來的情況。

要解決這個問題,我們可以 useEffect 的 cleanup function,例如:

// https://beta.reactjs.org/learn/you-might-not-need-an-effect#fetching-data
const TodoApp = () => {
const [todos, setTodos] = useState(null);

useEffect(() => {
// STEP 1:一開始 isCancel 預設是 false
let isCancel = false;

fetch(url)
.then((resp) => resp.json())
.then((data) => {
// STEP 3:如果在當下這個 effect 執行前,仍有執行過這個 effect
// 表示會執行到前一個 useEffect 的 cleanup function
// isCancel 就會是 true,因此不會執行到 setResult
// 以此確保只有最後一次的 useEffect 會呼叫到 setResult
// (以為最後一次執行的 useEffect 還不會呼叫到它自己的 cleanup function
if (!isCancel) {
setTodos(data);
}
});

return () => {
// STEP 2
// cleanup function 會在下次 effect 被執行「前」呼叫到
// 因此,可以利用這個時間點改變 isCancel 的值
isCancel = true;
};
}, []);

return <>{/* ... */}</>;
};

實際上,這類的 API 請求會建議使用第三方的 framework(例如,Next.js) 或 library 來處理(例如,react-query、swr),因為 fetching API 不只要處理 race-condition 的問題,還有包括 dedupe、cache 等等。

留意可以抽成 custom hooks 的情境

在元件中越少直接使用 useEffect,則越能提升專案的可維護性。因此,留意程式中可能重複的邏輯,以更 declarative 和目的導向(purpose-built API)的方式來設計成 custom hook。例如,上面段落提到 data-fetching 的邏輯,就可以抽成類似 useData 的 custom hook:

const useData = <T>(url: string) => {
const [result, setResult] = useState<T>();

useEffect(() => {
let isCancel = false;

fetch(url)
.then((resp) => resp.json() as unknown as T)
.then((data) => {
if (!isCancel) {
setResult(data);
}
});

return () => {
isCancel = true;
};
}, [url]);

return result;
};