v19.2Latest

リアクティブエフェクトのライフサイクル

エフェクトはコンポーネントとは異なるライフサイクルを持ちます。コンポーネントはマウント、更新、アンマウントされます。エフェクトができることは2つだけです:何かを同期し始めることと、後でその同期を停止することです。このサイクルは、エフェクトが時間とともに変化するpropsやstateに依存している場合、複数回発生する可能性があります。Reactは、エフェクトの依存関係が正しく指定されているかを確認するためのリンタールールを提供しています。これにより、エフェクトが最新のpropsとstateに同期され続けます。

学習内容
  • エフェクトのライフサイクルがコンポーネントのライフサイクルとどのように異なるか
  • 個々のエフェクトを独立して考える方法
  • エフェクトが再同期を必要とするタイミングとその理由
  • エフェクトの依存関係がどのように決定されるか
  • 値がリアクティブであるとはどういう意味か
  • 空の依存配列が意味するもの
  • Reactがリンターを使用して依存関係が正しいことを確認する方法
  • リンターの指摘に同意できない場合の対処法

エフェクトのライフサイクル

すべてのReactコンポーネントは同じライフサイクルを経ます:

  • コンポーネントは、画面に追加されるときにマウントされます。
  • コンポーネントは、新しいpropsやstateを受け取ると(通常はインタラクションに応じて)更新されます。
  • コンポーネントは、画面から削除されるときにアンマウントされます。

これはコンポーネントについて考える良い方法ですが、エフェクトについてはそうではありません。代わりに、各エフェクトをコンポーネントのライフサイクルから独立して考えるようにしてください。エフェクトは、外部システムを現在のpropsとstateに同期させる方法を記述します。コードが変化するにつれて、同期はより頻繁に、またはより少なく発生する必要があります。

この点を説明するために、コンポーネントをチャットサーバーに接続する次のエフェクトを考えてみましょう:

エフェクトの本体は、同期を開始する方法を指定します:

エフェクトによって返されるクリーンアップ関数は、同期を停止する方法を指定します:

直感的には、Reactがコンポーネントのマウント時に同期を開始し、コンポーネントのアンマウント時に同期を停止すると考えるかもしれません。しかし、これで話は終わりではありません!時には、コンポーネントがマウントされたままでも、同期を複数回開始および停止する必要がある場合もあります。

これがなぜ必要か、いつ発生するか、そしてこの動作をどのように制御できるかを見ていきましょう。

注意

一部のエフェクトはクリーンアップ関数を全く返しません。多くの場合、クリーンアップ関数を返すことが望ましいですが、返さない場合、Reactは空のクリーンアップ関数を返したかのように動作します。

同期が複数回発生する必要がある理由

このChatRoomコンポーネントが、ユーザーがドロップダウンで選択するroomIdpropを受け取ると想像してください。最初にユーザーが"general"ルームをroomIdとして選択したとします。アプリは"general"チャットルームを表示します:

UIが表示された後、Reactはエフェクトを実行して同期を開始します。それは"general"ルームに接続します:

ここまでは順調です。

その後、ユーザーがドロップダウンで別のルーム(例えば、"travel")を選択します。まず、ReactはUIを更新します:

次に何が起こるべきか考えてみましょう。ユーザーはUI上で選択されたチャットルームが"travel"であることを確認しています。しかし、前回実行されたEffectはまだ"general"ルームに接続されたままです。 roomIdプロパティが変更されたため、Effectが以前に行ったこと("general"ルームへの接続)はもはやUIと一致しません。

この時点で、Reactに2つのことを行ってほしいでしょう:

  1. 古いroomIdとの同期を停止する("general"ルームから切断する)
  2. 新しいroomIdとの同期を開始する("travel"ルームに接続する)

幸いなことに、あなたはすでにReactにこれら両方の方法を教えています!Effectの本体は同期を開始する方法を指定し、クリーンアップ関数は同期を停止する方法を指定しています。Reactが今行う必要があるのは、正しい順序で、正しいプロパティと状態を用いてそれらを呼び出すことだけです。それがどのように正確に行われるのか見てみましょう。

ReactがEffectを再同期する仕組み

あなたのChatRoomコンポーネントがroomIdプロパティの新しい値を受け取ったことを思い出してください。以前は"general"でしたが、今は"travel"です。ReactはEffectを再同期させ、別のルームに再接続する必要があります。

同期を停止するために、Reactは"general"ルームに接続した後にEffectが返したクリーンアップ関数を呼び出します。roomId"general"だったため、クリーンアップ関数は"general"ルームから切断します:

次に、Reactはこのレンダリング中に提供されたEffectを実行します。今回は、roomId"travel"なので、同期を開始して"travel"チャットルームに接続します(最終的にそのクリーンアップ関数も呼び出されるまで):

これにより、ユーザーがUIで選択したのと同じルームに接続されました。災難は回避されました!

コンポーネントが異なるroomIdで再レンダリングされるたびに、Effectは再同期されます。例えば、ユーザーがroomId"travel"から"music"に変更したとします。Reactは再びEffectのクリーンアップ関数を呼び出すことで同期を停止し"travel"ルームから切断)、新しいroomIdプロパティで本体を実行することで同期を開始します("music"ルームに接続します)。

最後に、ユーザーが別の画面に移動すると、ChatRoomはアンマウントされます。もはや接続を維持する必要は全くありません。ReactはEffectの同期を最後に停止し"music"チャットルームから切断します。

Effectの視点から考える

これまでに起こったことをChatRoomコンポーネントの視点からまとめてみましょう:

  1. ChatRoomがマウントされ、roomId"general"に設定された
  2. ChatRoomが更新され、roomId"travel"に設定された
  3. ChatRoomが更新され、roomId"music"に設定された
  4. ChatRoomがアンマウントされた

コンポーネントのライフサイクルのこれらの各時点で、あなたのEffectは異なることを行いました:

  1. あなたのEffectは"general"ルームに接続した
  2. あなたのEffectは"general"ルームから切断され、"travel"ルームに接続した
  3. あなたのEffectは"travel"ルームから切断され、"music"ルームに接続した
  4. あなたのEffectは"music"ルームから切断された

次に、Effect自体の観点から何が起こったかを考えてみましょう:

このコードの構造は、起こったことを重複しない時間の連続として見るように促すかもしれません:

  1. あなたのEffectは"general"ルームに接続した(切断されるまで)
  2. あなたのEffectは"travel"ルームに接続した(切断されるまで)
  3. あなたのEffectは"music"ルームに接続した(切断されるまで)

以前は、コンポーネントの観点から考えていました。コンポーネントの観点から見ると、Effectを「レンダリング後」や「アンマウント前」のような特定のタイミングで発火する「コールバック」や「ライフサイクルイベント」として考えるのが魅力的でした。この考え方はすぐに複雑になるので、避けるのが最善です。

代わりに、常に一度に一つの開始/停止サイクルに集中してください。コンポーネントがマウント中か、更新中か、アンマウント中かは関係ありません。あなたがする必要があるのは、同期を開始する方法と停止する方法を記述することだけです。それをうまく行えば、あなたのEffectは必要な回数だけ開始と停止を繰り返しても耐えられるようになります。

これは、JSXを作成するレンダリングロジックを書くときに、コンポーネントがマウント中か更新中かを考えないことを思い出させるかもしれません。あなたは画面上に何を表示すべきかを記述し、Reactが残りを解決します。

ReactがEffectの再同期を可能にすることを確認する方法

以下は、実際に試せるライブ例です。「チャットを開く」を押してChatRoomコンポーネントをマウントしてください:

コンポーネントが初めてマウントされるとき、3つのログが表示されることに注意してください:

  1. ✅ Connecting to "general" room at https://localhost:1234...(開発時のみ)
  2. ❌ Disconnected from "general" room at https://localhost:1234.(開発時のみ)
  3. ✅ Connecting to "general" room at https://localhost:1234...

最初の2つのログは開発時のみです。開発環境では、Reactは常に各コンポーネントを一度再マウントします。

Reactは、開発時に即座にEffectを再同期させることで、Effectが再同期できることを検証します。これは、ドアのロックが機能するか確認するために、ドアを開けて閉める作業を余分に行うことを思い起こさせるかもしれません。Reactは開発時にEffectを余分に一度開始・停止して、クリーンアップが適切に実装されているか確認します。

実際にEffectが再同期する主な理由は、Effectが使用するデータの一部が変更された場合です。上のサンドボックスで、選択されたチャットルームを変更してみてください。roomIdが変更されると、Effectが再同期することに注目してください。

しかし、再同期が必要となるより特殊なケースもあります。例えば、上のサンドボックスでチャットが開いている状態でserverUrlを編集してみてください。コードの編集に応じてEffectが再同期することに注目してください。将来的に、Reactは再同期に依存する機能を追加するかもしれません。

ReactがEffectを再同期する必要があることをどのように知るか

ReactがroomIdが変更された後にEffectを再同期する必要があることをどのように知ったのか疑問に思うかもしれません。それは、あなたがReactに伝えたからです。そのコードがroomIdに依存していることを、依存関係のリストに含めることで:

仕組みは以下の通りです:

  1. あなたはroomIdがpropsであり、時間とともに変化する可能性があることを知っていました。
  2. あなたはEffectがroomIdを読み取ることを知っていました(つまり、そのロジックは後で変化する可能性のある値に依存しています)。
  3. これが、Effectの依存関係として指定した理由です(つまり、roomIdが変更されたときに再同期するように)。

コンポーネントが再レンダリングされるたびに、Reactは渡された依存関係の配列を確認します。配列内のいずれかの値が、前回のレンダリング時に渡された同じ位置の値と異なる場合、ReactはEffectを再同期します。

例えば、初期レンダリング時に["general"]を渡し、次回のレンダリング時に["travel"]を渡した場合、Reactは"general""travel"を比較します。これらは異なる値なので(Object.isによる比較)、ReactはEffectを再同期します。一方、コンポーネントが再レンダリングされてもroomIdが変更されていない場合、Effectは同じルームに接続されたままになります。

各Effectは独立した同期プロセスを表す

既存のEffectと同じタイミングで実行する必要があるからといって、無関係なロジックをEffectに追加することは避けてください。例えば、ユーザーがルームを訪問したときに分析イベントを送信したいとします。既にroomIdに依存するEffectがあるので、そこに分析呼び出しを追加したくなるかもしれません:

しかし、後でこのEffectに接続を再確立する必要がある別の依存関係を追加することを想像してください。このEffectが再同期すると、意図していなかった同じルームに対してlogVisit(roomId)も呼び出されます。訪問のロギングは、接続とは別のプロセスです。これらを2つの別々のEffectとして記述してください:

コード内の各Effectは、独立した同期プロセスを表すべきです。

上記の例では、1つのEffectを削除しても、もう1つのEffectのロジックは壊れません。これは、それらが異なるものを同期していることを示す良い指標であり、分割するのが理にかなっています。一方、一貫性のあるロジックを別々のEffectに分割すると、コードは「よりクリーン」に見えるかもしれませんが、保守が難しくなる可能性があります。そのため、コードがよりクリーンに見えるかどうかではなく、プロセスが同じか別々かを考えるべきです。

Effectはリアクティブな値に「反応」する

あなたのEffectは2つの変数(serverUrlroomId)を読み取りますが、依存関係として指定したのはroomIdだけです:

なぜ serverUrlは依存関係に含める必要がないのでしょうか?

これは、serverUrlが再レンダリングによって変化しないためです。コンポーネントが何度再レンダリングされ、なぜ再レンダリングされるかに関わらず、常に同じ値です。serverUrlが決して変化しないのであれば、それを依存関係として指定することは意味がありません。結局のところ、依存関係は時間の経過とともに変化したときにのみ何かを行うのです!

一方、roomIdは再レンダリング時に異なる値になる可能性があります。コンポーネント内で宣言された props、state、およびその他の値は、リアクティブです。なぜなら、それらはレンダリング中に計算され、React のデータフローに参加するからです。

もしserverUrlが state 変数であれば、それはリアクティブになります。リアクティブな値は依存関係に含める必要があります:

依存関係としてserverUrlを含めることで、その値が変化した後に Effect が再同期されることを保証します。

このサンドボックスで、選択されたチャットルームを変更したり、サーバーURLを編集してみてください:

roomIdserverUrlのようなリアクティブな値を変更するたびに、Effect はチャットサーバーに再接続します。

依存関係が空の Effect が意味すること

もし serverUrlroomIdの両方をコンポーネントの外に移動したらどうなるでしょうか?

これで、Effect のコードはどのリアクティブな値も使用しなくなるため、その依存関係は空([])にすることができます。

コンポーネントの観点から考えると、空の[]依存関係配列は、この Effect がコンポーネントがマウントされたときにのみチャットルームに接続し、コンポーネントがアンマウントされたときにのみ切断することを意味します。(ただし、React は開発環境ではロジックをストレステストするために、追加で一度再同期することを覚えておいてください。)

しかし、Effect の観点から考えると、マウントやアンマウントについて考える必要はまったくありません。重要なのは、同期を開始および停止するために Effect が何を行うかを指定したことです。現在、リアクティブな依存関係はありません。しかし、将来的にユーザーが時間の経過とともにroomIdserverUrlを変更できるようにしたい場合(そしてそれらがリアクティブになる場合)、Effect のコードは変更されません。依存関係にそれらを追加するだけで済みます。

コンポーネント本体で宣言されたすべての変数はリアクティブです

リアクティブな値は props と state だけではありません。それらから計算された値もリアクティブです。もし props や state が変化すると、コンポーネントは再レンダリングされ、それらから計算された値も変化します。これが、Effect によって使用されるコンポーネント本体からのすべての変数が Effect の依存関係リストに含まれるべき理由です。

ユーザーがドロップダウンでチャットサーバーを選択できるが、設定でデフォルトサーバーを構成することもできるとします。設定の state はすでにコンテキストに置かれているので、そのコンテキストからsettingsを読み取るとします。ここで、props から選択されたサーバーとデフォルトサーバーに基づいてserverUrl を計算します:

この例では、serverUrlはpropsでもstate変数でもありません。レンダリング中に計算される通常の変数です。しかし、レンダリング中に計算されるため、再レンダリングによって変化する可能性があります。これがリアクティブである理由です。

コンポーネント内のすべての値(props、state、コンポーネント本体の変数を含む)はリアクティブです。リアクティブな値は再レンダリング時に変化する可能性があるため、Effectの依存関係としてリアクティブな値を含める必要があります。

言い換えれば、Effectはコンポーネント本体からのすべての値に「反応」します。

Deep Dive
グローバルまたはミュータブルな値を依存関係にできますか?

Reactはすべてのリアクティブな値が依存関係として指定されていることを検証します

リンターがReact用に設定されている場合、Effectのコードで使用されるすべてのリアクティブな値が依存関係として宣言されているかチェックします。例えば、これはリンターエラーです。なぜなら、roomIdserverUrlの両方がリアクティブだからです:

これはReactのエラーのように見えるかもしれませんが、実際にはReactはコードのバグを指摘しています。roomIdserverUrlは時間とともに変化する可能性がありますが、あなたはそれらが変化したときにEffectを再同期するのを忘れています。ユーザーがUIで異なる値を選択した後でも、初期のroomIdserverUrlに接続されたままになります。

バグを修正するには、リンターの提案に従って、roomIdserverUrlをEffectの依存関係として指定してください:

上記のサンドボックスでこの修正を試してください。リンターエラーが消え、必要時にチャットが再接続されることを確認してください。

注記

場合によっては、コンポーネント内で宣言されていても、値が決して変化しないことをReactが知っていることがあります。例えば、set関数から返されるuseStateと、useRefから返されるrefオブジェクトは安定しています。これらは再レンダリング時に変化しないことが保証されています。安定した値はリアクティブではないため、リストから省略しても構いません。含めても問題ありません:変化しないので、影響はありません。

再同期したくない場合の対処法

前の例では、roomIdserverUrlを依存関係としてリストすることでリンターエラーを修正しました。

しかし、代わりに、これらの値がリアクティブな値ではない、つまり再レンダリングの結果として変化できないことをリンターに「証明」することもできます。例えば、serverUrlroomIdがレンダリングに依存せず、常に同じ値を持つ場合、コンポーネントの外に移動できます。これで、依存関係にする必要はなくなります:

それらをEffect内に移動させることもできます。それらはレンダリング中に計算されないため、リアクティブではありません:

Effectはリアクティブなコードブロックです。その内部で読み取る値が変化すると、再同期が行われます。インタラクションごとに1回しか実行されないイベントハンドラとは異なり、Effectは同期が必要なときに常に実行されます。

依存関係を「選択」することはできません。依存関係には、Effect内で読み取るすべてのリアクティブな値を含める必要があります。これはリンターによって強制されます。これにより、無限ループやEffectの再同期が頻繁に発生するなどの問題が生じることがあります。これらの問題をリンターを抑制することで解決してはいけません!代わりに試すべきことは以下の通りです:

落とし穴

リンターはあなたの味方ですが、その力には限界があります。リンターは依存関係が間違っているときだけを知っています。各ケースを解決する最善の方法は知りません。リンターが依存関係を提案しても、それを追加するとループが発生する場合、リンターを無視すべきという意味ではありません。その値がリアクティブでなくなり、依存関係として必要なくなるように、Effectの内部(または外部)のコードを変更する必要があります。

既存のコードベースがある場合、以下のようにリンターを抑制するEffectがいくつかあるかもしれません:

次のページでは、ルールを破らずにこのコードを修正する方法を学びます。修正する価値は常にあります!

まとめ

  • コンポーネントはマウント、更新、アンマウントできます。
  • 各Effectは、それを囲むコンポーネントとは別のライフサイクルを持ちます。
  • 各Effectは、開始停止が可能な独立した同期プロセスを記述します。
  • Effectを記述および読み取るときは、コンポーネントの視点(マウント、更新、アンマウントの方法)ではなく、各Effectの視点(同期を開始および停止する方法)から考えてください。
  • コンポーネント本体で宣言された値は「リアクティブ」です。
  • リアクティブな値は時間とともに変化する可能性があるため、Effectを再同期させる必要があります。
  • リンターは、Effect内で使用されるすべてのリアクティブな値が依存関係として指定されていることを確認します。
  • リンターによってフラグが立てられるすべてのエラーは正当です。ルールを破らずにコードを修正する方法は常に存在します。

Try out some challenges

Challenge 1 of 5:Fix reconnecting on every keystroke #

In this example, the ChatRoom component connects to the chat room when the component mounts, disconnects when it unmounts, and reconnects when you select a different chat room. This behavior is correct, so you need to keep it working.

However, there is a problem. Whenever you type into the message box input at the bottom, ChatRoom also reconnects to the chat. (You can notice this by clearing the console and typing into the input.) Fix the issue so that this doesn’t happen.