[ReactDoc] React Hooks - useEffect
本文章內容來自:
- Using the Effect Hook @ React Docs
- A Complete Guide to useEffect @ Overreacted
- You Might Not Need an Effect @ React Docs Beta
useEffect
的使用時機類似在傳統 class 中使用 componentDidMount
, componentDidUpdate
和 componentWillUnmount
的生命週期,關於 useEffect 的基本說明可以參考「React Hooks 起步走」。
在 Dan Abramov 的 A 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。
和 componentDidMount
或 componentDidUpdate
不同的地方在於,在 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
範例程式碼
// 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
- Is it safe to omit functions from the list of dependencies? @ React Docs - Hooks FAQ
- Conditionally firing an effect @ React Docs - Hook API Reference
- Teaching React to Diff Your Effects @ Overreacted
在過去 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
內有使用到的state
或props
都定義在相依陣列中。
帶入空陣列:將相依陣列帶入空陣列(小心使用!)
如果該 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 內
- Is it safe to omit functions from the list of dependencies? @ React Docs - Hooks FAQ
- A Complete Guide to useEffect - Moving Functions Inside Effects @ overreacted
- A Complete Guide to useEffect - But I Can’t Put This Function Inside an Effect @ overreacted
如果在 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;
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
- How to fetch data with React Hooks?
- sample code @ code sandbox
在 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