前言:
此篇文章為React官方的You Might Not Need an Effect 透過我自己的理解來做翻譯與筆記。
🌟 這篇文章可以幫助我們更了解Effect, State的時機來改善效能,以及避免新手常掉入無限render的陷阱。
我們都知道Effect 是在render完畢、commit完畢、DOM更新完畢後才會去執行Effect,所以Effect沒用好可能會造成不必要的re-render。
常見的不必要的Effects:
你不需要在Effects 中做資料轉換(例如處理資料後又setState回去)
一些可以在Event Handler可以處理的事情
Effects 是讓我們來做一些**“外部系統”**需要處理的事情在做使用的。
基於props 或state 來更新 useState | Updating state based on props or state
這個範例展示了我們常常需要將fristName
結合lastName
成一個新變數。 在render的過程中React本來就會做calculated的動作,我們不需要多用一個useState
來保存fullName
。
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 Avoid: redundant state and unnecessary Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Good: calculated during rendering
const fullName = firstName + ' ' + lastName;
// ...
}
這個例子我們可以記住一個原則:
When something can be calculated from the existing props or state, don’t put it in state. Instead, calculate it during rendering. 當一個變數可以透過
props
或state
來取得結果,不要再將它宣告成一個state
。相反的你只需要在render時透過calculate來取得它。
在useEffect中做複雜的運算 | Caching expensive calculations
useEffect
不是讓我們拿來做複雜的運算,這個例子展示一個TodoList常見的狀況,當todos
與filter
更新時可以立刻取得一個visibleTodos
,我們可能會想到下面的寫法:
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// 🔴 Avoid: redundant state and unnecessary Effect
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ...
}
這個情況與1的情況有點像,我們不需要多用setState
來保存visibleTodos
,只需要像下面程式碼一寫撰寫即可,因為我們在render時本來就能做calculated的動作,大部分的案例下面的寫法都能運行!
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ This is fine if getFilteredTodos() is not slow.
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}
但也許某些案例時,getFilteredTodos()
非常耗效能(也許你有很多todos
?),在下面案例展示當你不希望一些其他像是newTodo
等的變數改變造成重新計算getFilteredTodos()
,可以用useMemo
來包裝visibleTodos
:
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
const visibleTodos = useMemo(() => {
// ✅ Does not re-run unless todos or filter change
return getFilteredTodos(todos, filter);
}, [todos, filter]);
// ...
}
或寫的更簡潔,像下面只有一行:
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ Does not re-run getFilteredTodos() unless todos or filter change
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// ...
}
像這樣透過useMemo
,在每一個render時React都會透過比對todos
或filter
是否有不同,來決定是否重新計算visibleTodos
。
當props 改變時重置元件狀態 | Resetting all state when a prop changes
這個例子是一個ProfilePage元件,傳入有userId
這個props。當傳入的userId
有變動時我們需要重置元件中的comment,第一個直覺我們可能會想到這樣撰寫程式碼:
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 🔴 Avoid: Resetting state on prop change in an Effect
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
React官方文件表示這是一個效率低下的做法,且會在第一次render時處理兩次comment = '';
。React官方提供另一個寫法,將ProfilePage分割一個component <Profile>
出來,並使用userId
作為key來傳給<Profile>
。
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// ✅ This and any other state below will reset on key change automatically
const [comment, setComment] = useState('');
// ...
}
為什麼這樣能達成props改變時重置狀態? 在官方文件這段有一段粗體字:
By passing userId as a key to the Profile component, you’re asking React to treat two Profile components with different userId as two different components that should not share any state.
當使用
userId
作為key
值給Profile component使用,等於你像React要求當userId
改變時Profile componet 即等於新的componet,新的cpmonent不會與舊component共享任何狀態。
再白話翻譯:React 提供的key
屬性本身特性即為當key
值改變,裡面的component 狀態會還原成初始化的狀態,來達成每個props 改變時都需要重置元件狀態的效果。
當props 改變同時修改state | Adjusting some state when a prop changes
這個範例與展示一個列表,當傳入的items
改變時,我們希望原本已經選定好的Selection
狀態能改成null
,下面是常見的錯誤寫法:
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 🔴 Avoid: Adjusting state on prop change in an Effect
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}
會造成的副作用與前幾個有點像,即第一次render時selection
這個state會被賦予值兩次造成re-render。
React官方提供另一個解決方式,在<List>
宣告一個放入props items
的state
,名為prevItems
,prevItems
會先記錄最初始的props items
。並且在每次render時我們都做一次比對props items
是否一致,不一致時才更新selection,可以避免前一個寫法造成的re-render。
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// Better: Adjust the state while rendering
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
React 官方推薦我們使用這種「儲存前一次render的資訊 Storing information from previous renders 」,來取代使用useEffect
帶入參數調整state
的方式。但事實上官方也知道這樣撰寫的pattern雖然比Effect高效,但也同時造成較難以閱讀與維護,大部分情況還是以參照「當props 改變時重置元件狀態」或 「常見的基於props 或state 來update useState」來做撰寫即可。
例如,我們不要儲存舊的items
,我們改將selection
這個state
改成selectedId
,並在render同時calculate selection
:
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ Best: Calculate everything during rendering
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
當使用者曾經selected
,值會儲存在selectedId
這個state
,接著props items
更新造成render
時,我們比對selectId
與items
,如果有比對到值還可以保持使用者的選擇狀態。
將邏輯移動到Event Handle | Sharing logic between event handlers
這個案例我覺得比較像是思考該事件的邏輯應該放在哪、該怎麼拆,避免濫用Effect。
如以下案例是一個ProductPage,有handleByClick()
與handleCheckoutClick()
兩個handler,和addToCart
、navigateTo
與showNotification
三個方法。 當點擊產品時會觸發addToCart(product)
、與顯示showNotification
提示已將產品加入購物車的Notification
:
function ProductPage({ product, addToCart }) {
// 🔴 Avoid: Event-specific logic inside an Effect
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
這樣的邏輯有明顯的錯誤,甚至會造成bug。想像如果ProductPage每次都會讀取cookie
或localStorge
來讀取使用者曾加入購物車的商品,這樣會造成刷新頁面時如果product.isInCart
為true
就會跳一次Notification
!
When you’re not sure whether some code should be in an Effect or in an event handler, ask yourself why this code needs to run. Use Effects only for code that should run because the component was displayed to the user
當你不確定一些code應該放在
Effect
還是event handler
時,試問自己這些code是什麼時候需要觸發?UseEffect
應該只用在當component display給使用者時所觸發使用。
在這個案例中,showNotification
應該是只有在使用者按下按鈕將產品加入購物車時才需要觸發,而不是因為頁面顯示。我們可以將按下按鈕將產品加入購物車的邏輯拆出來:
function ProductPage({ product, addToCart }) {
// ✅ Good: Event-specific logic is called from event handlers
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
這樣可以同時達成移除Effect
,並且修正bug,也讓邏輯比較清楚。
發出POST 請求 | Sending a POST request
這個案例是一個表單,會發出兩種POST
請求。一種是當component mounts必須發出分析事件,另一則是按下Submit按鈕觸發onSubmit
時發出/api/register
。
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ Good: This logic should run because the component was displayed
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
// 🔴 Avoid: Event-specific logic inside an Effect
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
// ...
}
這個案例正確與錯誤相對好懂,component mounts時發出分析事件使用Effect是正確的,除了在開發環境會有觸發兩次的問題,不過僅限開發環境所以沒關係。但將POST/api/register
放入Effect
不僅浪費效能,還會造成程式碼較難閱讀與維護的問題。
用「將邏輯移動到Event Handler」的邏輯來看 /api/register
,這個POST
request
的觸發實際並不是因為component display而觸發,是因為submitButton
被點擊而被觸發,所以較適合的寫法為將Event Handler邏輯集中:
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ Good: This logic runs because the component was displayed
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
function handleSubmit(e) {
e.preventDefault();
// ✅ Good: Event-specific logic is in the event handler
post('/api/register', { firstName, lastName });
}
// ...
}
串連的計算 | Chains of computations
當一個元件有許多相依的因素來計算各個變數,可能會聯想到這樣做:
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 🔴 Avoid: Chains of Effects that adjust the state solely to trigger each other
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1)
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('Good game!');
}, [isGameOver]);
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
} else {
setCard(nextCard);
}
}
// ...
這樣的寫法會有兩種問題: 這會造成這個component(以及它的children)效能極差,最糟的狀況會是(setCard → render → setGoldCardCount → render → setRound → render → setIsGameOver → render)
,有三次不必要的re-render產生。
即使你的代碼負擔不大、並沒有太多的效能問題。隨著產品發展下去來你可能會遇到這個**“串連Chain”**不符合新需求的狀況。想像接下來有一個「回到上一步」的需求,需要透過將每個state更新為過去的值來實現,但將state更新為過去的值又會觸發這段串連,進而又再次改變已經修改好的state。
這兩個問題都展示出這樣串連Effect會讓我們代碼擴展性極差。 在這個案例中較好的解決方法
解析出能在render時calculate的變數。
state調整至EventHandler中。
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// ✅ Calculate what you can during rendering
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}
// ✅ Calculate all the next state in the event handler
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}
// ...
app初始化 | Initializing the application
我們會希望在app初始化時運作一些邏輯,所以我們會將程式碼放在根元件的Effect中:
function App() {
// 🔴 Avoid: Effects with logic that should only ever run once
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
這兩個初始化的function看起來沒問題,但有時我們也希望即使在開發模式也只需要執行一次初始化。例如:在確認token
時可能因為在Strict mode執行兩次而造成token
失效。 如果我們希望一段程式碼不僅在根元件掛載(mount)時運行一次,而是只在整個app應用加載(load)時運行一次即可,我們可以在根元件外新增一個變數來追蹤是否已執行過初始化:
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ Only runs once per app load
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
甚至在根元件外直接執行,這樣可以達成app render前完成初始化的動作:
if (typeof window !== 'undefined') { // Check if we're running in the browser.
// ✅ Only runs once per app load
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
只運行一次有關初始化的程式碼對效能與避免非預期錯誤是很重要的,官網也強調將App初始化的邏輯都建議放在根元件或整個App的入口,不要在其他的component濫用這種方法。
通知父元件更改狀態 | Notifying parent components about state changes
這個案例展示一個用來切換狀態的子元件<Toggle>
,有一個isOn
的state
,可能為ture
或false
,使用者可能會用click或drag的方式改變isOn
,當isOn
的值改變時就必須通知父元件:
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
// 🔴 Avoid: The onChange handler runs too late
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])
function handleClick() {
setIsOn(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}
// ...
}
這個案例與前幾個案例差不多,都會造成元件re-render次,且邏輯不明確。isOn
的值會改變是來自於使用者click或Drag觸發isOn
的更新、接下來再觸發onChange
來通知父元件state改變。我們可以仿造前幾個案例將更新事件集中處理:
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
// ✅ Good: Perform all updates during the event that caused them
setIsOn(nextIsOn);
onChange(nextIsOn);
}
function handleClick() {
updateToggle(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateToggle(true);
} else {
updateToggle(false);
}
}
// ...
}
透過這個方式,並且React會將來自不同組件的change批次做處理,讓我們每次改變都只需要render一次。 再觀察這個案例可以發現,我們觸發isOn
的改變會同時改變兩個state
(子元件與父元件的state
),那我們可以改由父元件傳入isOn
的state
:
// ✅ Also good: the component is fully controlled by its parent
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
onChange(true);
} else {
onChange(false);
}
}
// ...
}
這樣更符合React的“Lifting state up” ,共享state
來減少子元件需要管理與同步變數的問題,只需要專心處理事件邏輯即可!
子元件傳值給父元件 | Passing data to the parent
這個案例展示當我們在子元件fetch API
後,將結果回傳給父元件:
function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
// 🔴 Avoid: Passing data to the parent in an Effect
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}
React官方希望我們遵循由父元件傳遞資料給子元件的資料流動,方便我們追蹤資料流向。如果子元件在Effect
中改變了父元件的state
會讓後續維護變得難以追蹤。如果父元件與子元件都需要同一組資料,我們應該保持由父元件來執行fetch API
再將資料傳給子元件:
function Parent() {
const data = useSomeAPI();
// ...
// ✅ Good: Passing data down to the child
return <Child data={data} />;
}
function Child({ data }) {
// ...
}
這樣修改能更保持數據可追蹤性,且更高效能!
從第三方library或瀏覽器拿取資料 | Subscribing to an external store
有時我們資料來源會是React本身以外的地方,例如第三方library或瀏覽器本身(built-in browser API)的資料。有些時候React沒辦法監聽這些數據是否有變動,所以常會出現使用Effect來進行手動監聽並state的動作:
function useOnlineStatus() {
// Not ideal: Manual store subscription in an Effect
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
以上案例展示了手動監聽外部資料(瀏覽器的navigator.onLine API
),因為最初渲染時這隻API並不在Serve上,所以在資料回來前都會initial HTML
都會將這個值渲染成ture
。 這個做法並非不可行,甚至很常見,但React有提供useSyncExternalStore這個Hook,專門處理監聽第三方資料的問題:
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
// ✅ Good: Subscribing to an external store with a built-in Hook
return useSyncExternalStore(
subscribe, // React won't resubscribe for as long as you pass the same function
() => navigator.onLine, // How to get the value on the client
() => true // How to get the value on the server
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
useSyncExternalStore
本身是一隻協助我們訂閱外部store的hook,初階用法可以詳閱useSyncExternalStore 官方介紹 Subscribing to a browser API的章節。
獲取數據 | Fetching Data
最大部分我們都會用useEffect
來發出API請求得到數據,想像在製作一個有Input提供使用者輸入搜尋內容,且會實時發出API請求並且setResult的頁面:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 🔴 Avoid: Fetching without cleanup logic
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
這個案例不需要像前幾個案例一樣,將邏輯移動到EventHandler中。
你可能會覺得矛盾,因為要觸發fetchResults
不就是因為使用者有輸入(typing
)或換頁(NextPageClick
)這個動作嗎?
回歸這個input 與頁面顯示的關聯,我們最主要希望當這個component顯示時,保持results
這個state的data 與query
、page
保持同步。
然後這樣會產生一個bug,想像當使用者快速輸入"hello"
,query
會快速接收"h"
、"he"
、"hell"
、"hello"
等變化。等於發送數個API請求,接著這些API請求可能會產生"he"
回傳的速度比"hello"
還要晚回來,接著會發生query
為"hello"
,但畫面顯示"he"
的搜尋結果。
這又稱“race condition”:發出兩個不同的API request 互相賽跑,但responce出現的速度不如預期。
想修正“race condition”的錯誤,應該加入一個cleanup function
去忽略
舊的responses:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
這樣可以確保當Effect連續觸發fetches data,我們只使用最後一個請求所得到的結果。
在Effect fetch data不單要思考“race condition”的問題。有些情況我們會希望catching responses (也許在希望使用者點擊上一頁按鈕時能保持原本狀態)、或處理Serve-side render,以及避免網路瀑布network waterfalls
(讓子元件不需等待任何父組件就執行fetch data)
這些問題適用於所有UI libray,這也是為什麼每個frameworks 都會提供比Effect 更多的fetch data的解決方案(例如Next.js, Remix, Gatsby, Expo (for native apps) …)
但不是每個專案的規模都需要framework,如果我們希望使用Effect 來fetch data並且考慮到上述問題的優化,可以參考下方案例:
function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
// ⬇️ 將results 使用自定義的hook回傳
const results = useData(`/api/search?${params}`);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}, [url]);
return data;
}
這個案例展示了自定義一個useData
的自定義hook,可以自行添加error handle或追蹤結果是否已經回傳(也可以在React ecosystem尋找很多種解決方案),雖然這種解決方案不如使用framework來得有效率,但將data fetch 移至自定義的hook能幫助我們更有效率的獲取data以及其他動作。
總結來說,當我們認為必須撰寫Effect時,都再思考一次我們是否有其他替代性方案?(如上方自定義的Hook或useSyncExternalStore
來替代Effect
),逐步在component減少Effect
的數量會發現後續維護越來越容易。
🌱 複習重點
如果有一個變數是因為
props
改變而需要重新calculate,我們不需要用state
定義它,僅需要宣告一個變數靠重新渲染時重新計算即可。如果有一個邏輯我們不知道是否要放在
Effect
,只需要問自己這個邏輯觸發的時機,如果不是因為componet display而觸發,就將邏輯集中在Event Handler。嘗試由父元件來分享
state
給子元件,不需要父子元件分別管理不同state
而需煩惱同步邏輯。想要重置component的
state
,嘗試pass將不同的key即可。當props改變時希望重置部分state,只需要在render時直接重置。
當我們需要同時更新多個component的
state
,最好在單個event一次解決。需要在不同component同步不同
state
時,請考慮lifting state up
(將資料流保持父傳子)在Effect fetch data時,務必注意“race condition”的問題。
參考文章: