v19.2Latest

您可能不需要 Effect

Effect 是 React 範式的一個逃生艙。它們讓您能夠「跳出」React,並將您的元件與某些外部系統(例如非 React 小工具、網路或瀏覽器 DOM)同步。如果沒有涉及外部系統(例如,當某些 props 或狀態變更時,您想要更新元件的狀態),您就不應該需要 Effect。移除不必要的 Effect 將使您的程式碼更容易理解、執行更快且更不容易出錯。

您將學習
  • 為何以及如何從您的元件中移除不必要的 Effect
  • 如何在不使用 Effect 的情況下快取昂貴的計算
  • 如何在不使用 Effect 的情況下重設和調整元件狀態
  • 如何在事件處理函式之間共享邏輯
  • 哪些邏輯應該移至事件處理函式
  • 如何通知父元件關於變更

如何移除不必要的 Effect

有兩種常見情況您不需要 Effect:

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

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

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

根據 props 或狀態更新狀態

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

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

當某些東西可以從現有的 props 或狀態計算出來時,不要將其放入狀態。相反,在渲染期間計算它。這使您的程式碼更快(您避免了額外的「級聯」更新)、更簡單(您移除了一些程式碼)且更不容易出錯(您避免了不同狀態變數彼此不同步導致的錯誤)。如果這種方法對您來說很陌生,Thinking in React解釋了什麼應該放入狀態。

快取昂貴的計算

此元件透過接收 props 傳入的todos並根據filterprop 對其進行篩選來計算visibleTodos。您可能會想將結果儲存在狀態中並從 Effect 更新它:

如同前面的例子,這既是不必要的也是低效的。首先,移除狀態和 Effect:

通常,這段程式碼沒問題!但或許getFilteredTodos()很慢,或者你有大量的todos。在這種情況下,你不希望因為某些不相關的狀態變數(例如getFilteredTodos()如果某些不相關的狀態變數(例如newTodo)發生變化而重新計算。

你可以透過將其包裹在「記憶化」一個昂貴的計算,透過將其包裹在useMemoHook 中來快取(或

注意

React Compiler可以自動為你記憶化昂貴的計算,在許多情況下消除了手動使用useMemo 的需要。

或者,寫成單行:

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

你包裹在 useMemo中的函數在渲染期間執行,因此這僅適用於純粹的計算。

Deep Dive
如何判斷計算是否昂貴?

當 prop 改變時重置所有狀態

這個ProfilePage 元件接收一個 userIdprop。頁面包含一個評論輸入框,你使用一個comment狀態變數來保存其值。有一天,你發現了一個問題:當你從一個個人檔案導航到另一個時,comment狀態不會被重置。因此,很容易意外地在錯誤用戶的個人檔案上發表評論。為了解決這個問題,你希望在comment 狀態變數每當 userId改變時清除:

這樣做效率很低,因為ProfilePage 及其子元件會先使用過時的值進行渲染,然後再渲染一次。這也很複雜,因為你需要在 每個ProfilePage內部擁有某些狀態的元件中都這樣做。例如,如果評論 UI 是巢狀的,你也會想要清除巢狀的評論狀態。

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

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

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

當屬性改變時調整部分狀態

有時,你可能希望在屬性改變時重置或調整部分狀態,但不是全部狀態。

這個List 元件接收一個 items 列表作為屬性,並在 selection狀態變數中維護所選項目。你希望在items屬性收到不同的陣列時,將selection 重置為 null

這同樣也不理想。每次 items改變時,List 及其子元件將首先使用過時的 selection值進行渲染。然後 React 將更新 DOM 並執行 Effect。最後,setSelection(null) 呼叫將導致 List及其子元件再次重新渲染,重新啟動整個過程。

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

儲存來自先前渲染的資訊可能難以理解,但這比在 Effect 中更新相同的狀態要好。在上面的範例中,setSelection是在渲染期間直接呼叫的。React 將在List立即 退出 return語句後重新渲染它。React 尚未渲染List的子元件或更新 DOM,因此這讓List子元件可以跳過渲染過時的selection 值。

當你在渲染期間更新元件時,React 會丟棄返回的 JSX 並立即重試渲染。為了避免非常緩慢的級聯重試,React 只允許你在渲染期間更新同一個 元件的狀態。如果你在渲染期間更新另一個元件的狀態,你將看到錯誤。像 items !== prevItems這樣的條件對於避免迴圈是必要的。你可以像這樣調整狀態,但任何其他副作用(如更改 DOM 或設定計時器)應保留在事件處理函數或 Effect 中,以保持元件純粹。

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

現在完全不需要「調整」狀態。如果具有所選 ID 的項目在清單中,它會保持被選中。如果不在,在渲染期間計算出的selection 將會是 null,因為沒有找到匹配的項目。這種行為不同,但可以說更好,因為對 items的大多數更改都會保留選擇。

在事件處理函數之間共享邏輯

假設您有一個產品頁面,有兩個按鈕(購買和結帳),都可以讓您購買該產品。您希望在用戶將產品放入購物車時顯示通知。在兩個按鈕的點擊處理函數中都呼叫showNotification() 感覺很重複,因此您可能會想把這個邏輯放在 Effect 中:

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

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

這樣既移除了不必要的 Effect,又修復了錯誤。

發送 POST 請求

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

讓我們套用與前面例子相同的標準。

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

然而,/api/register的 POST 請求並非由表單被顯示所引起。你只想在一個特定的時間點發送請求:當用戶按下按鈕時。它只應該在那個特定的互動中發生。刪除第二個 Effect 並將該 POST 請求移到事件處理函式中:

當你決定是否將某些邏輯放在事件處理函式或 Effect 中時,你需要回答的主要問題是,從用戶的角度來看,這是什麼樣的邏輯。如果這個邏輯是由特定的互動引起的,就把它保留在事件處理函式中。如果它是由用戶看到元件出現在畫面上引起的,就把它保留在 Effect 中。

計算鏈

有時你可能會想將多個 Effect 串聯起來,每個 Effect 根據其他狀態來調整一部分狀態:

這段程式碼有兩個問題。

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

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

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

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

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

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

初始化應用程式

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

你可能會想把它放在頂層元件的 Effect 中:

然而,你很快就會發現它在開發環境中會執行兩次。 這可能會導致問題——例如,也許它會使驗證權杖失效,因為該函數並非設計為被呼叫兩次。一般來說,你的元件應該能夠承受被重新掛載。這包括你的頂層 App 元件。

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

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

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

通知父元件關於狀態變更

假設你正在編寫一個具有內部Toggle 元件,其內部狀態 isOn 可以是 truefalse。有幾種不同的切換方式(透過點擊或拖曳)。你希望在 Toggle內部狀態變更時通知父元件,因此你公開了一個onChange

和之前一樣,這並不理想。Toggle先更新其狀態,然後 React 更新畫面。接著 React 執行 Effect,該 Effect 會呼叫從父元件傳入的onChange 函數。現在父元件將更新其自身的狀態,開始另一次渲染過程。最好是在單次過程中完成所有事情。

刪除 Effect,改為在同一個事件處理函數中更新兩個元件的狀態:

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

您或許也可以完全移除狀態,改為從父元件接收 isOn

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

將資料傳遞給父元件

這個Child元件獲取一些資料,然後在 Effect 中將其傳遞給Parent 元件:

在 React 中,資料從父元件流向其子元件。當您在畫面上看到錯誤時,可以透過向上追蹤元件鏈,直到找到傳遞錯誤 prop 或擁有錯誤狀態的元件,來追溯資訊的來源。當子元件在 Effect 中更新其父元件的狀態時,資料流會變得非常難以追蹤。由於子元件和父元件都需要相同的資料,讓父元件獲取該資料,並將其向下傳遞給子元件:

這樣更簡單,並且保持了可預測的資料流:資料從父元件向下流向子元件。

訂閱外部儲存庫

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

這裡,元件訂閱了一個外部資料儲存庫(在此例中,是瀏覽器的navigator.onLineAPI)。由於此 API 在伺服器上不存在(因此無法用於初始 HTML),初始狀態設為true。每當該資料儲存庫的值在瀏覽器中發生變化時,元件就會更新其狀態。

雖然通常會使用 Effect 來實現此目的,但 React 有一個專門用於訂閱外部儲存庫的 Hook,更推薦使用它。刪除 Effect 並將其替換為對useSyncExternalStore的呼叫:

這種方法比使用 Effect 手動將可變資料同步到 React 狀態更不容易出錯。通常,您會像上面的useOnlineStatus()一樣編寫一個自訂 Hook,這樣您就不需要在各個元件中重複此程式碼。閱讀更多關於從 React 元件訂閱外部儲存庫的資訊。

獲取資料

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

不需要將這個 fetch 移到事件處理函式中。

這似乎與之前需要將邏輯放入事件處理函式的例子相矛盾!然而,請考慮到,主要的獲取原因並非輸入事件。搜尋輸入通常會從 URL 預先填入,使用者可能在不觸碰輸入框的情況下透過「上一頁」和「下一頁」進行導航。

無論pagequery來自何處。只要這個元件是可見的,您就希望讓results與網路資料保持同步,以反映當前的pagequery。這就是為什麼它是一個 Effect。

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

要修復競爭條件,您需要新增一個清理函式來忽略過時的回應:

這確保了當您的 Effect 獲取資料時,除了最後一個請求的回應外,所有其他回應都將被忽略。

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

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

如果您不使用框架(並且不想自己構建),但又希望讓 Effect 中的資料獲取更符合人體工學,可以考慮將您的獲取邏輯提取到自訂 Hook 中,如下例所示:

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

一般來說,每當您不得不訴諸於編寫 Effect 時,請留意何時可以將某個功能提取到一個具有更宣告式且專用 API 的自訂 Hook 中,例如上面的useData。您的元件中原始的useEffect呼叫越少,您就越容易維護您的應用程式。

總結

  • 如果你可以在渲染期間計算某些東西,你就不需要 Effect。
  • 要快取昂貴的計算,請改用useMemo 而不是 useEffect
  • 要重置整個元件樹的狀態,請傳遞一個不同的key給它。
  • 要因應 prop 的變化而重置特定的狀態位元,請在渲染期間設定它。
  • 因為元件被 顯示而執行的程式碼應該放在 Effects 中,其餘的應該放在事件中。
  • 如果你需要更新多個元件的狀態,最好在單一事件中進行。
  • 每當你嘗試同步不同元件中的狀態變數時,請考慮提升狀態。
  • 你可以使用 Effects 來獲取資料,但你需要實作清理以避免競態條件。

Try out some challenges

Challenge 1 of 4:Transform data without Effects #

The TodoList below displays a list of todos. When the “Show only active todos” checkbox is ticked, completed todos are not displayed in the list. Regardless of which todos are visible, the footer displays the count of todos that are not yet completed.

Simplify this component by removing all the unnecessary state and Effects.