你可能不需要 Effect

Effects 是 React 範式中的一個逃生艙口。它們允許你「跳出」 React 並將你的元件與某些外部系統同步,例如非 React widget、網路或瀏覽器 DOM。如果沒有涉及外部系統(例如,如果你想在某些 props 或狀態更改時更新元件的狀態),則你不需要 Effect。移除不必要的 Effects 將使你的程式碼更易於理解、執行速度更快且更容易除錯。

你將學習到

  • 為什麼以及如何從你的元件中移除不必要的 Effects
  • 如何在沒有 Effects 的情況下快取昂貴的計算
  • 如何在沒有 Effects 的情況下重設和調整元件狀態
  • 如何在事件處理程式之間共享邏輯
  • 哪些邏輯應該移至事件處理程式
  • 如何將變更通知父元件

如何移除不必要的 Effects

有兩種常見情況下你不需要 Effects

  • 你不需要 Effects 來轉換渲染的資料。 例如,假設你想在顯示列表之前先過濾它。你可能會想撰寫一個 Effect,在列表更改時更新狀態變數。然而,這是沒有效率的。當你更新狀態時,React 會先呼叫你的元件函式來計算螢幕上應該顯示的內容。然後 React 會將這些變更「提交」到 DOM,更新螢幕。然後 React 會執行你的 Effects。如果你的 Effect 也立即更新狀態,則會從頭開始重新啟動整個流程!為了避免不必要的渲染過程,請在元件的頂層轉換所有資料。每當你的 props 或狀態更改時,該程式碼都會自動重新執行。
  • 你不需要 Effects 來處理使用者事件。 例如,假設你想發送一個 /api/buy POST 請求並在使用者購買產品時顯示通知。在「購買」按鈕點擊事件處理程式中,你確切地知道發生了什麼事。等到 Effect 執行時,你不知道使用者做了什麼(例如,點擊了哪個按鈕)。這就是為什麼你通常會在相應的事件處理程式中處理使用者事件。

你確實需要 Effects 來與外部系統同步。例如,你可以撰寫一個 Effect,使 jQuery widget 與 React 狀態保持同步。你也可以使用 Effects 來擷取資料:例如,你可以將搜尋結果與目前的搜尋查詢同步。請記住,現代的框架 提供了比直接在元件中撰寫 Effects 更有效率的內建資料擷取機制。

為了幫助你獲得正確的直覺,讓我們來看一些常見的具體例子!

根據 props 或狀態更新狀態

假設你有一個具有兩個狀態變數的元件:firstNamelastName。你想通過串連它們來計算一個 fullName。此外,你希望 fullNamefirstNamelastName 更改時更新。你的第一直覺可能是新增一個 fullName 狀態變數並在 Effect 中更新它

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]);
// ...
}

這比必要的更複雜。它效率也很低:它使用 fullName 的過時值進行整個渲染過程,然後立即使用更新的值重新渲染。移除狀態變數和 Effect

function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Good: calculated during rendering
const fullName = firstName + ' ' + lastName;
// ...
}

如果某些值可以從現有的 props 或 state 計算出來,就不要把它放在 state 裡面。 而是在渲染過程中計算它。 這樣可以讓你的程式碼更快(避免額外的「級聯」更新)、更簡潔(減少程式碼),並且更不容易出錯(避免因不同狀態變數彼此不同步而導致的錯誤)。如果這種方法對你來說很陌生,React 哲學 解釋了什麼應該放在 state 裡。

快取耗時計算

這個元件透過接收 props 的 todos 並根據 filter prop 進行篩選來計算 visibleTodos。你可能會想將結果儲存在 state 中,並透過 Effect 更新它

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]);

// ...
}

如同前面的例子,這既不必要也沒有效率。首先,移除 state 和 Effect

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 Hook 中來快取(或 「記憶」)它

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]);
// ...
}

這告訴 React,除非 todosfilter 發生變化,否則你不希望內部函式重新執行。 React 會在初始渲染期間記住 getFilteredTodos() 的返回值。在下一次渲染期間,它會檢查 todosfilter 是否與上次不同。如果它們與上次相同,useMemo 將返回它儲存的最後一個結果。但如果它們不同,React 將再次呼叫內部函式(並儲存其結果)。

你用 useMemo 包裝的函式會在渲染期間執行,所以這只適用於 純粹的計算(pure calculations)。

深入探討

如何判斷計算是否耗時?

一般來說,除非你正在建立或迴圈數千個物件,否則它可能不會很耗時。如果你想更有把握,可以新增一個 console.log 來測量一段程式碼所花費的時間

console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');

執行你要測量的互動(例如,在輸入框中輸入文字)。然後你會在控制台中看到類似 filter array: 0.15ms 的日誌。如果記錄的總時間加起來很長(例如,1ms 或更多),那麼記憶該計算可能是有意義的。作為實驗,你可以將計算包裝在 useMemo 中,以驗證該互動的記錄總時間是否減少

console.time('filter array');
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter); // Skipped if todos and filter haven't changed
}, [todos, filter]);
console.timeEnd('filter array');

useMemo 不會讓*第一次*渲染更快。它只幫助你跳過更新時不必要的工作。

請記住,你的機器可能比你的使用者的機器更快,因此最好透過人為減速來測試效能。例如,Chrome 提供了 CPU 節流 選項來執行此操作。

另請注意,在開發環境中測量效能不會給你最準確的結果。(例如,當 嚴格模式 開啟時,你會看到每個元件渲染兩次而不是一次。)要獲得最準確的時間,請將你的應用程式建置為生產環境,並在與使用者相同的裝置上進行測試。

prop 改變時重設所有狀態

這個 ProfilePage 元件接收一個 userId prop。頁面包含一個留言輸入框,你使用一個 comment 狀態變數來保存它的值。有一天,你發現了一個問題:當你從一個個人檔案導覽到另一個個人檔案時,comment 狀態沒有被重設。因此,很容易不小心在錯誤的使用者個人檔案上發布留言。為了修復這個問題,你希望每當 userId 改變時就清除 comment 狀態變數

export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');

// 🔴 Avoid: Resetting state on prop change in an Effect
useEffect(() => {
setComment('');
}, [userId]);
// ...
}

這樣做效率很低,因為 ProfilePage 和它的子元件會先用舊的值渲染,然後再渲染一次。這也很複雜,因為你需要在 ProfilePage 內部*每個*具有狀態的元件中都這樣做。例如,如果留言 UI 是巢狀的,你也需要清除巢狀留言的狀態。

相反的,你可以透過給每個使用者個人檔案一個明確的 key 來告訴 React 每個使用者的個人檔案在概念上是*不同*的個人檔案。將你的元件分成兩個,並從外部元件傳遞一個 key 屬性到內部元件

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('');
// ...
}

通常,React 會在相同的組件渲染於相同位置時保留其狀態。透過將 userId 作為 key 傳遞給 Profile 組件,您要求 React 將兩個具有不同 userIdProfile 組件視為兩個不應共享任何狀態的不同組件。每當 key(您已設定為 userId)變更時,React 都會重新建立 DOM 並重設 Profile 組件及其所有子組件的狀態。現在,在不同個人檔案之間瀏覽時,comment 欄位將會自動清除。

請注意,在此範例中,只有外部的 ProfilePage 組件會被匯出並對專案中的其他檔案可見。渲染 ProfilePage 的組件不需要將 key 傳遞給它:它們將 userId 作為一般 prop 傳遞。ProfilePage 將其作為 key 傳遞給內部的 Profile 組件是一個實作細節。

當 prop 變更時調整某些狀態

有時,您可能希望在 prop 變更時重設或調整部分狀態,而不是全部。

這個 List 組件接收一個 items 列表作為 prop,並在 selection 狀態變數中維護選取的項目。您希望每當 items prop 收到不同的陣列時,將 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]);
// ...
}

這也不是理想的。每次 items 變更時,List 及其子組件將會先以過時的 selection 值進行渲染。然後 React 將會更新 DOM 並執行 Effects。最後,setSelection(null) 呼叫將會導致 List 及其子組件再次重新渲染,重新啟動整個流程。

首先刪除 Effect。相反地,直接在渲染期間調整狀態。

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);
}
// ...
}

像這樣儲存先前渲染的資訊可能難以理解,但這比在 Effect 中更新相同的狀態更好。在上面的範例中,setSelection 直接在渲染期間被呼叫。React 將會在以 return 陳述式結束後*立即*重新渲染 List。React 尚未渲染 List 子組件或更新 DOM,因此這讓 List 子組件可以跳過渲染過時的 selection 值。

當您在渲染期間更新組件時,React 會捨棄返回的 JSX 並立即重試渲染。為了避免非常緩慢的連鎖重試,React 只允許您在渲染期間更新*相同*組件的狀態。如果您在渲染期間更新另一個組件的狀態,您將會看到錯誤。需要像 items !== prevItems 這樣的條件來避免迴圈。您可以像這樣調整狀態,但任何其他副作用(例如更改 DOM 或設定逾時)都應該保留在事件處理程式或 Effects 中,以保持組件的純粹性

雖然這種模式比 Effect 更有效率,但大多數組件也不需要它。 無論您如何操作,根據 props 或其他狀態調整狀態都會使您的資料流程更難理解和除錯。請務必檢查您是否可以使用 key 重設所有狀態在渲染期間計算所有內容。例如,您可以儲存選取的*項目 ID*,而不是儲存(和重設)選取的*項目*:

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;
// ...
}

現在根本不需要「調整」狀態。如果具有選取 ID 的項目在列表中,則它保持選取狀態。如果沒有,則在渲染期間計算的 selection 將會是 null,因為找不到相符的項目。這種行為不同,但可以說是更好的,因為大多數對 items 的更改都會保留選擇。

在事件處理程式之間共用邏輯

假設您有一個產品頁面,其中包含兩個按鈕(購買和結帳),這兩個按鈕都允許您購買該產品。您希望在使用者將產品放入購物車時顯示通知。在兩個按鈕的點擊處理程式中呼叫 showNotification() 感覺很重複,因此您可能會想將此邏輯放在 Effect 中。

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');
}
// ...
}

這個 Effect 是不必要的。它也很可能會導致錯誤。例如,假設您的應用程式在頁面重新載入之間「記住」購物車。如果您將產品新增到購物車一次並重新整理頁面,則通知將會再次出現。每次您重新整理該產品的頁面時,它都會繼續出現。這是因為 product.isInCart 在頁面載入時就已經是 true,因此上面的 Effect 將會呼叫 showNotification()

當你不確定某些程式碼應該放在 Effect 還是事件處理器中時,問問自己為什麼這段程式碼需要執行。Effect 僅適用於因為元件顯示給使用者而需要執行的程式碼。 在這個例子中,通知應該出現是因為使用者按下按鈕,而不是因為頁面被顯示!刪除 Effect 並將共享邏輯放入一個從兩個事件處理器呼叫的函式中。

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,也修復了錯誤。

發送 POST 請求

這個 Form 元件會發送兩種 POST 請求。它在掛載時會發送一個分析事件。當您填寫表單並點擊提交按鈕時,它會向 /api/register 端點發送一個 POST 請求。

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 });
}
// ...
}

讓我們套用與前一個範例相同的標準。

分析 POST 請求應該保留在 Effect 中。這是因為發送分析事件的原因是表單被顯示。(它在開發過程中會觸發兩次,但請參閱此處了解如何處理。)

但是,/api/register POST 請求不是由表單被顯示引起的。您只想在一個特定的時間點發送請求:當使用者按下按鈕時。它應該只在該特定互動時發生。刪除第二個 Effect 並將該 POST 請求移至事件處理器中。

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 });
}
// ...
}

當您選擇是否將某些邏輯放入事件處理器或 Effect 中時,您需要回答的主要問題是從使用者的角度來看它是什麼樣的邏輯。如果此邏輯是由特定互動引起的,請將其保留在事件處理器中。如果它是由使用者看到螢幕上的元件引起的,請將其保留在 Effect 中。

計算鏈

有時您可能會想將每個 Effect 鏈接起來,每個 Effect 都會根據其他狀態調整一部分狀態。

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);
}
}

// ...

這段程式碼有兩個問題。

第一個問題是效率非常低:元件(及其子元件)必須在鏈中的每個 set 呼叫之間重新渲染。在上面的例子中,在最壞的情況下(setCard → 渲染 → setGoldCardCount → 渲染 → setRound → 渲染 → setIsGameOver → 渲染),下方樹狀結構會有三次不必要的重新渲染。

第二個問題是,即使它不慢,隨著程式碼的演變,您也會遇到您編寫的「鏈」不符合新需求的情況。想像您正在新增一種逐步瀏覽遊戲移動歷史記錄的方法。您可以透過將每個狀態變數更新為過去的值來做到這一點。但是,將 card 狀態設定為過去的值將再次觸發 Effect 鏈並更改您正在顯示的資料。這樣的程式碼通常是僵化且脆弱的。

在這種情況下,最好在渲染期間計算您可以計算的內容,並在事件處理器中調整狀態。

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!');
}
}
}
}

// ...

這樣效率更高。此外,如果您實作了一種檢視遊戲歷史記錄的方法,現在您將能夠將每個狀態變數設定為過去的移動,而不會觸發調整每個其他值的 Effect 鏈。如果您需要在多個事件處理器之間重複使用邏輯,您可以提取一個函式並從這些處理器呼叫它。

請記住,在事件處理器內部,狀態的行為就像快照。 例如,即使在您呼叫 setRound(round + 1) 之後,round 變數仍將反映使用者點擊按鈕時的值。如果您需要使用下一個值進行計算,請手動定義它,例如 const nextRound = round + 1

在某些情況下,您無法直接在事件處理器中計算下一個狀態。例如,想像一個具有多個下拉式選單的表單,其中下一個下拉式選單的選項取決於前一個下拉式選單的選定值。那麼,一系列 Effect 是適當的,因為您正在與網路同步。

初始化應用程式

某些邏輯應該只在應用程式載入時執行一次。

您可能會想將其放在頂層元件的 Effect 中。

function App() {
// 🔴 Avoid: Effects with logic that should only ever run once
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}

但是,您很快就會發現它在開發過程中會執行兩次。 這可能會導致問題——例如,它可能會使驗證令牌失效,因為該函式並非設計為被呼叫兩次。一般來說,您的元件應該能夠承受重新掛載。這包括您的頂層 App 元件。

儘管它在實際生產環境中可能永遠不會重新掛載,但在所有元件中遵循相同的限制可以更輕鬆地移動和重複使用程式碼。如果某些邏輯必須每次應用程式載入執行一次,而不是每次元件掛載執行一次,請新增一個頂層變數來追蹤它是否已經執行過。

let didInit = false;

function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ Only runs once per app load
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}

您也可以在模組初始化期間和應用程式渲染之前執行它。

if (typeof window !== 'undefined') { // Check if we're running in the browser.
// ✅ Only runs once per app load
checkAuthToken();
loadDataFromLocalStorage();
}

function App() {
// ...
}

頂層的程式碼在導入元件時會執行一次——即使它最終沒有被渲染。為了避免在導入任意元件時速度變慢或出現意外行為,請勿過度使用此模式。將應用程式範圍的初始化邏輯保留給根元件模組,例如 App.js 或應用程式的進入點。

通知父組件狀態變化

假設您正在編寫一個帶有內部 isOn 狀態的 Toggle 組件,該狀態可以是 truefalse。 有幾種不同的方法可以切換它(通過點擊或拖動)。 您希望在 Toggle 內部狀態發生變化時通知父組件,因此您可以公開一個 onChange 事件並從 Effect 中調用它。

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);
}
}

// ...
}

與之前一樣,這並不理想。 Toggle 會先更新其狀態,然後 React 更新畫面。 然後 React 運行程式碼 Effect,它會調用從父組件傳遞的 onChange 函式。 現在父組件將更新其自身的狀態,開始另一個渲染過程。 最好在單次渲染過程中完成所有操作。

刪除 Effect,並在同一個事件處理程式中更新*兩個*組件的狀態。

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);
}
}

// ...
}

使用這種方法,Toggle 組件及其父組件都會在事件期間更新其狀態。 React 會將來自不同組件的更新批次處理,因此只會有一次渲染過程。

您也可以完全移除狀態,而是從父組件接收 isOn

// ✅ 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);
}
}

// ...
}

「將狀態提升」讓父組件通過切換父組件自身的狀態來完全控制 Toggle。 這意味著父組件將包含更多邏輯,但總體而言需要擔心的狀態更少。 每當您嘗試使兩個不同的狀態變數保持同步時,請嘗試改用提升狀態!

將資料傳遞給父組件

這個 Child 組件擷取一些資料,然後在 Effect 中將其傳遞給 Parent 組件。

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 中,資料從父組件流向其子組件。 當您在螢幕上看到錯誤時,您可以通過向上追蹤組件鏈來追蹤資訊的來源,直到找到傳遞錯誤 prop 或具有錯誤狀態的組件。 當子組件在 Effects 中更新其父組件的狀態時,資料流將變得非常難以追蹤。 由於子組件和父組件都需要相同的資料,因此讓父組件擷取該資料,並將其*向下傳遞*給子組件。

function Parent() {
const data = useSomeAPI();
// ...
// ✅ Good: Passing data down to the child
return <Child data={data} />;
}

function Child({ data }) {
// ...
}

這更簡單,並保持資料流可預測:資料從父組件向下流向子組件。

訂閱外部儲存

有時,您的組件可能需要訂閱 React 狀態之外的一些資料。 這些資料可能來自第三方程式庫或內建瀏覽器 API。 由於這些資料可能會在 React 不知情的情況下發生變化,因此您需要手動將組件訂閱到它。 這通常使用 Effect 完成,例如:

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 在伺服器上不存在(因此無法用於初始 HTML),因此初始狀態設定為 true。 每當瀏覽器中該資料儲存的值發生變化時,組件就會更新其狀態。

雖然通常使用 Effects 來執行此操作,但 React 有一個專門用於訂閱外部儲存的 Hook,建議改用它。 刪除 Effect 並將其替換為對 useSyncExternalStore 的呼叫。

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();
// ...
}

這種方法比使用 Effect 將可變資料手動同步到 React 狀態更不容易出錯。 通常,您會編寫一個自訂 Hook,例如上面的 useOnlineStatus(),這樣您就不需要在個別組件中重複此程式碼。 深入了解如何從 React 組件訂閱外部儲存

擷取資料

許多應用程式使用 Effects 來啟動資料擷取。 編寫像這樣的資料擷取 Effect 相當常見:

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);
}
// ...
}

您*不需要*將此擷取移至事件處理程式。

這似乎與之前的範例相矛盾,在之前的範例中,您需要將邏輯放入事件處理程式中! 但是,請考慮到擷取的主要原因並非*鍵入事件*。 搜尋輸入通常會從網址預先填入,使用者可能會在不觸碰輸入的情況下導覽「上一頁」和「下一頁」。

pagequery 來自哪裡並不重要。 在此組件可見時,您希望將 results 與來自網路的資料同步目前的 pagequery。 這就是它是 Effect 的原因。

然而,上面的程式碼有一個 bug。想像一下,你快速輸入 "hello"。那麼 query 會從 "h" 變成 "he""hel""hell",最後是 "hello"。這將會觸發個別的網路請求,但無法保證回應的到達順序。例如,"hell" 的回應可能會在 "hello" 的回應*之後*才到達。由於它最後會呼叫 setResults(),你將會顯示錯誤的搜尋結果。這稱為「競爭條件」:兩個不同的請求互相「競爭」,並以與預期不同的順序到達。

要解決競爭條件,你需要新增一個清除函式來忽略過時的回應。

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 提取資料時,除了最後一個請求之外,所有回應都會被忽略。

處理競爭條件並不是實作資料提取的唯一難題。你可能還需要考慮快取回應(以便使用者可以點擊「返回」並立即看到之前的畫面)、如何在伺服器上提取資料(以便初始伺服器渲染的 HTML 包含提取的內容而不是載入動畫),以及如何避免網路瀑布流(以便子元件可以在不等待每個父元件的情況下提取資料)。

這些問題適用於任何 UI 函式庫,而不僅僅是 React。解決它們並非易事,這就是為什麼現代框架提供比在 Effects 中提取資料更有效率的內建資料提取機制。

如果你不使用框架(也不想建構自己的框架),但希望讓 Effects 的資料提取更加符合人體工學,可以考慮將提取邏輯提取到自訂 Hook 中,就像此範例一樣。

function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
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;
}

你可能還需要新增一些邏輯來處理錯誤並追蹤內容是否正在載入。你可以自己建構這樣的 Hook,或者使用 React 生態系統中已有的眾多解決方案之一。**雖然單靠這樣做不如使用框架的內建資料提取機制有效率,但將資料提取邏輯移到自訂 Hook 中將使日後採用高效的資料提取策略變得更容易。**

一般來說,每當你必須 resorting to writing Effects 時,請注意何時可以將部分功能提取到具有更具宣告性和專用 API 的自訂 Hook 中,例如上面的 useData。你的元件中原始的 useEffect 呼叫越少,你會發現維護應用程式就越容易。

重點回顧

  • 如果可以在渲染期間計算某些內容,則不需要 Effect。
  • 要快取昂貴的計算,請新增 useMemo 而不是 useEffect
  • 要重設整個元件樹的狀態,請傳遞不同的 key 給它。
  • 要根據 prop 變更重設特定狀態,請在渲染期間設定它。
  • 因為元件被*顯示*而執行的程式碼應該放在 Effects 中,其餘的應該放在事件中。
  • 如果你需要更新多個元件的狀態,最好在單一事件中完成。
  • 每當你嘗試同步不同元件中的狀態變數時,請考慮將狀態提升。
  • 你可以使用 Effects 提取資料,但你需要實作清除功能以避免競爭條件。

挑戰 1 4:
在沒有 Effects 的情況下轉換資料

下面的 TodoList 顯示一個待辦事項清單。當勾選「僅顯示進行中的待辦事項」核取方塊時,已完成的待辦事項將不會顯示在清單中。無論顯示哪些待辦事項,頁尾都會顯示尚未完成的待辦事項數量。

透過移除所有不必要的狀態和 Effects 來簡化此元件。

import { useState, useEffect } from 'react';
import { initialTodos, createTodo } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const [activeTodos, setActiveTodos] = useState([]);
  const [visibleTodos, setVisibleTodos] = useState([]);
  const [footer, setFooter] = useState(null);

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

  useEffect(() => {
    setVisibleTodos(showActive ? activeTodos : todos);
  }, [showActive, todos, activeTodos]);

  useEffect(() => {
    setFooter(
      <footer>
        {activeTodos.length} todos left
      </footer>
    );
  }, [activeTodos]);

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <NewTodo onAdd={newTodo => setTodos([...todos, newTodo])} />
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
      {footer}
    </>
  );
}

function NewTodo({ onAdd }) {
  const [text, setText] = useState('');

  function handleAddClick() {
    setText('');
    onAdd(createTodo(text));
  }

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
    </>
  );
}