Skip to main content

[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
Last updated on