エフェクトが必要ない場合もある
エフェクトはReactのパラダイムからの脱出口です。Reactの「外側に踏み出し」、コンポーネントを非Reactウィジェット、ネットワーク、ブラウザのDOMなどの外部システムと同期させることを可能にします。外部システムが関与していない場合(例えば、propsやstateが変化したときにコンポーネントのstateを更新したい場合)、エフェクトは必要ありません。不要なエフェクトを削除することで、コードは理解しやすくなり、実行速度が向上し、エラーが発生しにくくなります。
学習内容
- コンポーネントから不要なエフェクトを削除する理由と方法
- エフェクトを使わずに高コストな計算をキャッシュする方法
- エフェクトを使わずにコンポーネントのstateをリセット・調整する方法
- イベントハンドラ間でロジックを共有する方法
- どのロジックをイベントハンドラに移動すべきか
- 親コンポーネントに変更を通知する方法
不要なエフェクトを削除する方法
エフェクトが不要な一般的なケースは2つあります:
- レンダリング用にデータを変換するのにエフェクトは必要ありません。例えば、リストを表示する前にフィルタリングしたいとします。リストが変更されたときにstate変数を更新するエフェクトを書きたくなるかもしれません。しかし、これは非効率です。stateを更新すると、Reactはまずコンポーネント関数を呼び出して画面に表示すべきものを計算します。次にReactは「コミット」してDOMに変更を反映し、画面を更新します。その後、Reactはエフェクトを実行します。もしエフェクトがさらに即座にstateを更新すると、このプロセス全体が最初からやり直しになります!不要なレンダリングパスを避けるために、すべてのデータ変換はコンポーネントのトップレベルで行います。そのコードは、propsやstateが変化するたびに自動的に再実行されます。
- ユーザーイベントを処理するのにエフェクトは必要ありません。例えば、ユーザーが商品を購入したときに
/api/buyにPOSTリクエストを送信し、通知を表示したいとします。購入ボタンのクリックイベントハンドラでは、何が起こったか正確にわかります。エフェクトが実行される時点では、ユーザーが何をしたか(例えば、どのボタンがクリックされたか)わかりません。そのため、ユーザーイベントは通常、対応するイベントハンドラで処理します。
エフェクトが必要なのは、外部システムと同期する場合です。例えば、jQueryウィジェットをReactのstateと同期させるエフェクトを書くことができます。また、エフェクトでデータを取得することもできます:例えば、検索結果を現在の検索クエリと同期させることができます。ただし、最新のフレームワークは、コンポーネント内で直接エフェクトを書くよりも効率的な組み込みのデータ取得メカニズムを提供していることに注意してください。
正しい直感を得るために、いくつかの一般的な具体例を見てみましょう!
propsまたはstateに基づいてstateを更新する
2つのstate変数firstNameとlastNameを持つコンポーネントがあるとします。これらを連結してfullNameを計算したいとします。さらに、fullNameはfirstNameまたはlastNameが変化するたびに更新されるようにしたいとします。最初の直感では、fullNameというstate変数を追加し、エフェクト内で更新したくなるかもしれません:
これは必要以上に複雑です。また非効率でもあります:古いfullNameの値でレンダリングパス全体を行い、その後すぐに更新された値で再レンダリングします。state変数とエフェクトを削除しましょう:
既存のpropsやstateから計算できるものは、stateに入れないでください。代わりに、レンダリング中に計算します。これにより、コードは高速化(余分な「連鎖的」更新を回避)、簡素化(コードの一部を削除)、エラー発生率の低下(異なるstate変数が互いに同期しなくなるバグを回避)します。このアプローチが新しいと感じる場合は、Thinking in Reactで何をstateに入れるべきか説明しています。
高コストな計算のキャッシュ
このコンポーネントは、propsで受け取ったtodosをfilterpropに従ってフィルタリングしてvisibleTodosを計算します。結果をstateに保存し、エフェクトから更新したくなるかもしれません:
先ほどの例と同様に、これは不要であり非効率的です。まず、ステートとEffectを削除します:
通常、このコードは問題ありません!しかし、getFilteredTodos()が遅いか、todosが大量にある場合があります。その場合、getFilteredTodos()を、newTodoのような無関係なステート変数が変更されたときに再計算したくないでしょう。
高負荷な計算をキャッシュ(または「メモ化」)するには、useMemoフックでラップします:
注記
React Compilerは高負荷な計算を自動的にメモ化できるため、多くの場合で手動のuseMemoが不要になります。
または、1行で記述すると:
これは、内部の関数をtodosまたはfilterのいずれかが変更されない限り再実行したくないことをReactに伝えます。Reactは初期レンダリング時にgetFilteredTodos()の戻り値を記憶します。次のレンダリング時には、todosまたはfilterが異なるかどうかをチェックします。前回と同じであれば、useMemoは保存していた最後の結果を返します。しかし、異なる場合、Reactは内部関数を再度呼び出し(その結果を保存します)。
useMemoでラップする関数はレンダリング中に実行されるため、これは純粋な計算に対してのみ機能します。
プロパティが変更されたときにすべての状態をリセットする
このProfilePageコンポーネントはuserIdプロパティを受け取ります。ページにはコメント入力欄があり、その値を保持するためにcomment状態変数を使用しています。ある日、問題に気づきます:あるプロフィールから別のプロフィールに移動するとき、comment状態がリセットされません。その結果、誤ったユーザーのプロフィールにコメントを投稿してしまう可能性があります。この問題を修正するには、userIdが変更されるたびにcomment状態変数をクリアしたいと考えています:
これは非効率的です。なぜなら、ProfilePageとその子コンポーネントが最初に古い値でレンダリングされ、その後再びレンダリングされるからです。また、すべてのコンポーネントで、ProfilePage内部に状態を持つコンポーネントごとにこれを行う必要があるため、複雑です。例えば、コメントUIがネストされている場合、ネストされたコメントの状態もクリアしたいでしょう。
代わりに、各ユーザーのプロフィールを概念的には異なるプロフィールとして扱うようにReactに指示することができます。明示的なキーを与えることで実現します。コンポーネントを2つに分割し、外側のコンポーネントから内側のコンポーネントにkey属性を渡します:
通常、Reactは同じ場所に同じコンポーネントがレンダリングされるとき、状態を保持します。をkeyとしてProfileコンポーネントに渡すことで、異なるuserIdを持つ2つのProfileコンポーネントを、状態を共有すべきではない2つの異なるコンポーネントとして扱うようにReactに要求しています。キー(ここではuserIdに設定)が変更されるたびに、ReactはDOMを再作成し、状態をリセットします。Profileこれで、プロフィール間を移動する際にcommentフィールドが自動的にクリアされます。
この例では、外側のProfilePageコンポーネントのみがエクスポートされ、プロジェクト内の他のファイルから見えるようになっていることに注意してください。ProfilePageをレンダリングするコンポーネントは、キーを渡す必要はありません。それらはuserIdを通常のプロップとして渡します。ProfilePageがそれをkeyとして内側のProfileコンポーネントに渡すことは、実装の詳細です。
プロップが変更されたときに一部の状態を調整する
時には、プロップが変更されたときに状態の一部をリセットまたは調整したいが、すべてをリセットしたくない場合があります。
このListコンポーネントは、itemsのリストをpropsとして受け取り、選択されたアイテムをselection状態変数で保持します。itemspropが異なる配列を受け取るたびに、selectionをnullにリセットしたいとします:
これも理想的ではありません。itemsが変わるたびに、最初は古いselection値でListとその子コンポーネントがレンダリングされます。その後、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よりも効率的ですが、ほとんどのコンポーネントはこれも必要としません。どのように行うにせよ、propsや他のstateに基づいてstateを調整することは、データフローを理解しにくく、デバッグを困難にします。代わりに、keyで全てのstateをリセットできるか、あるいはレンダリング中に全てを計算できるかを常に確認してください。例えば、選択されたアイテムを保存(およびリセット)する代わりに、選択されたアイテムIDを保存できます:
これで、状態を「調整」する必要はまったくありません。選択されたIDを持つアイテムがリスト内にあれば、それは選択されたままです。もしなければ、レンダリング中に計算されるselectionは、一致するアイテムが見つからなかったためnullになります。この動作は異なりますが、itemsへの変更のほとんどが選択状態を保持するため、より良いと言えるでしょう。
イベントハンドラ間でのロジックの共有
ある商品ページに、その商品を購入できる2つのボタン(購入とチェックアウト)があるとします。ユーザーが商品をカートに入れるたびに通知を表示したいとします。両方のボタンのクリックハンドラでshowNotification()を呼び出すのは繰り返しのように感じるので、このロジックをEffectに置きたくなるかもしれません:
このEffectは不要です。また、ほとんどの場合バグの原因になります。例えば、アプリがページの再読み込み間でショッピングカートを「記憶」しているとします。一度商品をカートに追加してページを更新すると、通知が再び表示されます。その商品ページを更新するたびに表示され続けます。これは、ページ読み込み時にproduct.isInCartがすでにtrueになっているため、上記のEffectがshowNotification()を呼び出すからです。
あるコードをEffectに入れるべきかイベントハンドラに入れるべきかわからない場合は、なぜこのコードを実行する必要があるのか自問してください。Effectは、コンポーネントがユーザーに表示されたために実行されるべきコードに対してのみ使用してください。この例では、通知は、ページが表示されたからではなく、ユーザーがボタンを押したために表示されるべきです!Effectを削除し、共有ロジックを両方のイベントハンドラから呼び出される関数に置きましょう:
これにより、不要なEffectが削除され、バグも修正されます。
POSTリクエストの送信
このFormコンポーネントは、2種類のPOSTリクエストを送信します。マウント時にアナリティクスイベントを送信します。フォームに入力して送信ボタンをクリックすると、/api/registerエンドポイントにPOSTリクエストを送信します:
前の例と同じ基準を適用してみましょう。
アナリティクスのPOSTリクエストはEffectに残すべきです。これは、アナリティクスイベントを送信する理由がフォームが表示されたことだからです。(開発環境では2回発火しますが、その対処法についてはこちらを参照してください。)
しかし、/api/registerへのPOSTリクエストは、フォームが表示されたことによって引き起こされるものではありません。リクエストを送信したいのは、特定の瞬間、つまりユーザーがボタンを押したときだけです。それはその特定のインタラクションでのみ発生するべきです。2つ目のEffectを削除し、そのPOSTリクエストをイベントハンドラに移動します:
あるロジックをイベントハンドラに入れるかEffectに入れるかを選択する際に答えるべき主な質問は、ユーザーの視点から見てそれがどのような種類のロジックなのかということです。このロジックが特定のインタラクションによって引き起こされるものであれば、イベントハンドラに残します。それがユーザーがコンポーネントを見たことによって引き起こされるものであれば、Effectに残します。
計算の連鎖
時々、それぞれが他の状態に基づいて状態の一部を調整するEffectを連鎖させたくなるかもしれません:
このコードには2つの問題があります。
1つ目の問題は、非常に非効率であることです。コンポーネント(とその子コンポーネント)は、チェーン内の各set呼び出しの間に再レンダリングする必要があります。上の例では、最悪の場合(setCard→ レンダリング →setGoldCardCount→ レンダリング →setRound→ レンダリング →setIsGameOver→ レンダリング)、ツリーの下位部分が3回不要に再レンダリングされます。
2つ目の問題は、たとえ遅くなかったとしても、コードが進化するにつれて、書いた「チェーン」が新しい要件に合わないケースに遭遇する可能性があることです。ゲームの手の履歴をステップ実行する方法を追加することを想像してみてください。各状態変数を過去の値に更新することで実装するでしょう。しかし、card状態を過去の値に設定すると、Effectチェーンが再びトリガーされ、表示しているデータが変更されてしまいます。このようなコードはしばしば硬直的で壊れやすいものです。
このような場合、レンダリング中に計算できるものは計算し、状態の調整はイベントハンドラー内で行う方が良いでしょう:
これは非常に効率的です。また、ゲーム履歴を表示する方法を実装する場合、各状態変数を過去の手に設定しても、他のすべての値を調整するEffectチェーンをトリガーすることなく行えるようになります。複数のイベントハンドラー間でロジックを再利用する必要がある場合は、関数を抽出して、それらのハンドラーから呼び出すことができます。
イベントハンドラー内では、状態はスナップショットのように振る舞うことを思い出してください。例えば、setRound(round + 1)を呼び出した後でも、round変数はユーザーがボタンをクリックした時点の値を反映します。計算に次の値を使用する必要がある場合は、const nextRound = round + 1のように手動で定義してください。
場合によっては、イベントハンドラー内で直接次の状態を計算できないこともあります。例えば、複数のドロップダウンを持つフォームで、次のドロップダウンのオプションが前のドロップダウンの選択値に依存する場合を想像してください。その場合、ネットワークとの同期を行っているため、Effectのチェーンが適切です。
アプリケーションの初期化
一部のロジックは、アプリが読み込まれたときに一度だけ実行されるべきです。
トップレベルコンポーネントのEffectに配置したくなるかもしれません:
しかし、開発環境ではこれが2回実行されることにすぐ気づくでしょう。これは問題を引き起こす可能性があります。例えば、関数が2回呼び出されるように設計されていないため、認証トークンを無効にしてしまうかもしれません。一般的に、コンポーネントは再マウントされることに耐性を持つべきです。これにはトップレベルのAppコンポーネントも含まれます。
実際の本番環境では再マウントされることはないかもしれませんが、すべてのコンポーネントで同じ制約に従うことで、コードの移動や再利用が容易になります。一部のロジックがコンポーネントのマウントごとに1回ではなく、アプリの読み込みごとに1回実行される必要がある場合は、トップレベルの変数を追加して、それがすでに実行されたかどうかを追跡します:
モジュールの初期化時やアプリのレンダリング前に実行することもできます:
トップレベルのコードは、コンポーネントがインポートされたときに一度実行されます。たとえレンダリングされなくてもです。任意のコンポーネントをインポートする際の速度低下や予期しない動作を避けるために、このパターンを過度に使用しないでください。アプリ全体の初期化ロジックは、App.jsのようなルートコンポーネントモジュールやアプリケーションのエントリーポイントに保持してください。
親コンポーネントへの状態変更の通知
内部にToggleコンポーネントを書いているとします。このコンポーネントはisOnという内部状態を持ち、trueまたはfalseのいずれかになります。トグルする方法はいくつかあります(クリックやドラッグなど)。Toggleの内部状態が変化するたびに親コンポーネントに通知したいので、onChange
先ほどと同様に、これは理想的ではありません。Toggleはまず自身の状態を更新し、Reactが画面を更新します。その後、ReactがEffectを実行し、親コンポーネントから渡されたonChange関数を呼び出します。これにより親コンポーネントが自身の状態を更新し、別のレンダーパスが開始されます。すべてを単一のパスで行う方が良いでしょう。
Effectを削除し、代わりに同じイベントハンドラ内で両方のコンポーネントの状態を更新します:
このアプローチでは、Toggleコンポーネントとその親コンポーネントの両方が、イベント中に状態を更新します。Reactは異なるコンポーネントからの更新をバッチ処理するため、レンダーパスは1回だけになります。
状態を完全に削除し、代わりに親コンポーネントからisOnを受け取ることもできるかもしれません:
「状態のリフトアップ」により、親コンポーネントが自身の状態を切り替えることでToggleを完全に制御できます。これにより親コンポーネントにはより多くのロジックが必要になりますが、全体として心配すべき状態は少なくなります。2つの異なる状態変数を同期させようとするときは、代わりに状態のリフトアップを試してみてください!
親へのデータの受け渡し
このChildコンポーネントはデータを取得し、それをEffect内でParentコンポーネントに渡します:
Reactでは、データは親コンポーネントから子コンポーネントへと流れます。画面上で何か問題が見つかった場合、間違ったpropsを渡している、または間違った状態を持っているコンポーネントを見つけるまで、コンポーネントチェーンを遡って情報の出所を追跡できます。子コンポーネントがEffect内で親コンポーネントの状態を更新すると、データフローは非常に追跡しづらくなります。子と親の両方が同じデータを必要とする場合、親コンポーネントにそのデータを取得させ、代わりに子コンポーネントに渡すようにします:
これはよりシンプルで、データフローを予測可能に保ちます:データは親から子へと流れます。
外部ストアへの購読
時折、コンポーネントはReactの状態の外部にあるデータを購読する必要があるかもしれません。このデータはサードパーティのライブラリや組み込みのブラウザAPIからのものである可能性があります。このデータはReactの知らないところで変更される可能性があるため、手動でコンポーネントを購読させる必要があります。これは多くの場合、Effectを使用して行われます。例えば:
ここでは、コンポーネントは外部データストア(この場合はブラウザのnavigator.onLineAPI)を購読しています。このAPIはサーバー上には存在しないため(初期HTMLには使用できません)、初期状態はtrueに設定されています。ブラウザ内でそのデータストアの値が変更されるたびに、コンポーネントは自身の状態を更新します。
この目的でEffectを使用することは一般的ですが、Reactには外部ストアを購読するための専用のフックがあり、代わりにこちらが推奨されます。Effectを削除し、useSyncExternalStoreの呼び出しに置き換えます:
このアプローチは、Effectを使用して変更可能なデータをReactの状態に手動で同期させるよりもエラーが発生しにくくなります。通常は、上記のuseOnlineStatus()のようなカスタムフックを作成し、個々のコンポーネントでこのコードを繰り返さないようにします。Reactコンポーネントからの外部ストアの購読について詳しく読む。
データの取得
多くのアプリは、データ取得を開始するためにEffectを使用します。次のようなデータ取得Effectを書くことは非常に一般的です:
このフェッチをイベントハンドラに移動する必要はありません。
これは、ロジックをイベントハンドラに置く必要があった以前の例と矛盾しているように見えるかもしれません!しかし、フェッチの主な理由がタイピングイベントではないことを考えてみてください。検索入力はURLから事前に入力されることが多く、ユーザーは入力に触れずに「戻る」や「進む」でナビゲートする可能性があります。
pageとqueryがどこから来るかは重要ではありません。このコンポーネントが表示されている間は、現在のresults同期をネットワークからのデータとpageおよびqueryに対して維持したいのです。これがEffectである理由です。
しかし、上記のコードにはバグがあります。"hello"と素早く入力することを想像してみてください。すると、queryは"h"から"he"、"hel"、"hell"、そして"hello"へと変化します。これにより個別のフェッチが開始されますが、レスポンスがどの順序で到着するかは保証されません。例えば、"hell"のレスポンスが後最後にsetResults()を呼び出すため、間違った検索結果が表示されることになります。これは「競合状態」と呼ばれます:2つの異なるリクエストが互いに「競争」し、予想とは異なる順序で到着したのです。
競合状態を修正するには、古いレスポンスを無視するためのクリーンアップ関数を追加する必要があります:
これにより、Effectがデータをフェッチする際、最後にリクエストされたもの以外のすべてのレスポンスが無視されることが保証されます。
競合状態の処理は、データフェッチの実装における唯一の困難ではありません。レスポンスのキャッシュ(ユーザーが「戻る」をクリックして前の画面を即座に表示できるようにするため)、サーバー上でのデータフェッチ方法(初期のサーバーレンダリングされたHTMLにスピナーではなくフェッチされたコンテンツを含めるため)、ネットワークのウォーターフォールの回避方法(子コンポーネントがすべての親の完了を待たずにデータをフェッチできるようにするため)についても考える必要があるかもしれません。
これらの問題はReactに限らず、あらゆるUIライブラリに当てはまります。これらを解決することは簡単ではなく、そのため現代のフレームワークは、Effectでデータをフェッチするよりも効率的な組み込みのデータフェッチメカニズムを提供しています。
フレームワークを使用しない(かつ独自に構築したくない)が、Effectからのデータフェッチをより使いやすくしたい場合は、以下の例のようにフェッチロジックをカスタムフックに抽出することを検討してください:
エラー処理やコンテンツがロード中かどうかの追跡のためのロジックも追加したいと思うでしょう。このようなフックを自分で構築するか、Reactエコシステムですでに利用可能な多くのソリューションの一つを使用できます。これだけではフレームワークの組み込みデータフェッチメカニズムほど効率的ではありませんが、データフェッチロジックをカスタムフックに移動することで、後で効率的なデータフェッチ戦略を採用しやすくなります。
一般的に、Effectを書かざるを得ないときは、上記のuseDataコンポーネント内の生のuseEffect呼び出しが少なければ少ないほど、アプリケーションのメンテナンスが容易になります。
まとめ
- レンダリング中に計算できるものは、Effectは必要ありません。
- 高コストな計算をキャッシュするには、
useMemoをuseEffectの代わりに追加します。 - コンポーネントツリー全体の状態をリセットするには、異なる
keyを渡します。 - 特定の状態の一部をpropsの変更に応じてリセットするには、レンダリング中に設定します。
- コンポーネントが表示されたために実行されるコードはEffectに、それ以外はイベントに含めるべきです。
- 複数のコンポーネントの状態を更新する必要がある場合は、単一のイベント中に行う方が良いでしょう。
- 異なるコンポーネント間で状態変数を同期させようとするときは、状態のリフトアップを検討してください。
- Effectでデータを取得できますが、競合状態を避けるためにクリーンアップを実装する必要があります。
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.
