エフェクトとの同期
一部のコンポーネントは外部システムと同期する必要があります。例えば、Reactの状態に基づいて非Reactコンポーネントを制御したり、サーバー接続を設定したり、コンポーネントが画面に表示されたときに分析ログを送信したりしたい場合があります。エフェクトを使用すると、レンダリング後にコードを実行できるため、コンポーネントをReact外のシステムと同期させることができます。
学習内容
- エフェクトとは何か
- エフェクトとイベントの違い
- コンポーネントでエフェクトを宣言する方法
- 不要なエフェクトの再実行をスキップする方法
- 開発環境でエフェクトが2回実行される理由とその修正方法
エフェクトとは何か、そしてイベントとどう違うのか?
エフェクトについて学ぶ前に、Reactコンポーネント内の2種類のロジックに慣れておく必要があります:
- レンダリングコード(UIの記述で紹介)はコンポーネントのトップレベルに存在します。ここではpropsとstateを受け取り、変換して、画面に表示したいJSXを返します。レンダリングコードは純粋でなければなりません。数学の公式のように、結果を計算するだけで、それ以外のことは行いません。
- イベントハンドラー(インタラクティブ性の追加で紹介)はコンポーネント内のネストされた関数で、計算するだけでなく何かを実行します。イベントハンドラーは、入力フィールドを更新したり、製品購入のためのHTTP POSTリクエストを送信したり、ユーザーを別の画面にナビゲートしたりする可能性があります。イベントハンドラーには、特定のユーザーアクション(例えば、ボタンクリックやタイピング)によって引き起こされる「副作用」(プログラムの状態を変更する)が含まれます。
これだけでは不十分な場合があります。画面に表示されるたびにチャットサーバーに接続する必要があるChatRoomコンポーネントを考えてみましょう。サーバーへの接続は純粋な計算ではありません(副作用です)ので、レンダリング中には発生できません。しかし、ChatRoomが表示される原因となるクリックのような単一の特定のイベントはありません。
エフェクトを使用すると、特定のイベントではなく、レンダリング自体によって引き起こされる副作用を指定できます。チャットでのメッセージ送信はイベントです。なぜなら、ユーザーが特定のボタンをクリックすることによって直接引き起こされるからです。一方、サーバー接続の設定はエフェクトです。なぜなら、どのようなインタラクションによってコンポーネントが表示されたかに関わらず発生するべきだからです。エフェクトは、画面が更新された後のコミットの終わりに実行されます。これは、Reactコンポーネントを外部システム(ネットワークやサードパーティライブラリなど)と同期させる良いタイミングです。
注記
ここおよびこのテキストの後半では、大文字の「Effect」は上記のReact固有の定義、つまりレンダリングによって引き起こされる副作用を指します。より広範なプログラミング概念を指す場合は、「副作用」と言います。
エフェクトが必要ない場合もある
コンポーネントにエフェクトを追加することを急がないでください。エフェクトは通常、Reactコードから「踏み出して」外部システムと同期するために使用されることを覚えておいてください。これにはブラウザAPI、サードパーティウィジェット、ネットワークなどが含まれます。エフェクトが他の状態に基づいて一部の状態を調整するだけの場合、エフェクトが必要ないかもしれません。
エフェクトの書き方
エフェクトを書くには、次の3つのステップに従います:
- エフェクトを宣言する。デフォルトでは、エフェクトはすべてのコミット後に実行されます。
- エフェクトの依存関係を指定する。ほとんどのエフェクトは、すべてのレンダリング後にではなく、必要なときだけ再実行されるべきです。例えば、フェードインアニメーションはコンポーネントが表示されたときにのみトリガーされるべきです。チャットルームへの接続と切断は、コンポーネントが表示されたときと非表示になったとき、またはチャットルームが変更されたときにのみ発生するべきです。依存関係
- 必要に応じてクリーンアップを追加する。一部のエフェクトは、実行していたことを停止、元に戻す、またはクリーンアップする方法を指定する必要があります。例えば、「接続」には「切断」、「購読」には「購読解除」、「フェッチ」には「キャンセル」または「無視」が必要です。クリーンアップ関数を返すことでこれを行う方法を学びます。
これらの各ステップを詳しく見ていきましょう。
ステップ1:エフェクトを宣言する
コンポーネントでエフェクトを宣言するには、ReactからuseEffectフックをインポートします:
次に、コンポーネントのトップレベルでそれを呼び出し、Effect内にコードを記述します:
コンポーネントがレンダリングされるたびに、Reactは画面を更新し、その後 useEffect内のコードを実行します。言い換えると、useEffectは、レンダリングが画面に反映されるまでコードの実行を「遅延」させます。
Effectを使用して外部システムと同期させる方法を見てみましょう。<VideoPlayer> Reactコンポーネントを考えてみます。isPlayingプロップを渡すことで、再生中か一時停止かを制御できれば便利です:
カスタムのVideoPlayerコンポーネントは、ブラウザ組み込みの<video>タグをレンダリングします:
しかし、ブラウザの<video> タグにはisPlayingプロップはありません。それを制御する唯一の方法は、DOM要素に対してplay() とpause()メソッドを手動で呼び出すことです。ビデオが現在再生されるべきかどうかを示すisPlayingプロップの値と、play() やpause()のような呼び出しを同期させる必要があります。
まず、refを取得して<video>DOMノードを参照する必要があります。
レンダリング中にplay() やpause()を呼び出そうとするかもしれませんが、それは正しくありません:
このコードが正しくない理由は、レンダリング中にDOMノードに対して何かを実行しようとしているからです。Reactでは、レンダリングは純粋な計算であり、DOMの変更のような副作用を含むべきではありません。
さらに、VideoPlayerが初めて呼び出されたとき、そのDOMはまだ存在しません!play()やpause()を呼び出すためのDOMノードはまだなく、ReactはJSXを返すまでどのDOMを作成すべきかわからないからです。
ここでの解決策は、副作用をでラップして、レンダリング計算の外に移動することです:
DOMの更新をEffectでラップすることで、Reactに最初に画面を更新させ、その後でEffectが実行されます。
あなたのVideoPlayerコンポーネントがレンダリングされるとき(初回または再レンダリング時)、いくつかのことが起こります。まず、Reactが画面を更新し、<video>タグが適切なpropsでDOM内にあることを保証します。次に、ReactがあなたのEffectを実行します。最後に、Effectがplay()またはpause()をisPlayingの値に応じて呼び出します。
再生/一時停止を複数回押して、ビデオプレーヤーがisPlayingの値と同期し続ける様子を確認してください:
この例では、Reactの状態と同期させた「外部システム」はブラウザのメディアAPIでした。同様のアプローチで、レガシーな非Reactコード(jQueryプラグインなど)を宣言的なReactコンポーネントにラップすることもできます。
実際には、ビデオプレーヤーの制御はもっと複雑であることに注意してください。play()の呼び出しが失敗する可能性があり、ユーザーがブラウザの組み込みコントロールを使って再生や一時停止を行う可能性もあります。この例は非常に簡略化されており、不完全です。
落とし穴
デフォルトでは、Effectはすべてのレンダーの後に実行されます。そのため、以下のようなコードは無限ループを引き起こします:
Effectはレンダーの結果として実行されます。状態を設定するとレンダーがトリガーされます。Effect内ですぐに状態を設定することは、電源コンセントをそれ自体に差し込むようなものです。Effectが実行され、状態を設定し、それが再レンダーを引き起こし、その結果Effectが実行され、再び状態を設定し、これが別の再レンダーを引き起こし、というように続きます。
Effectは通常、コンポーネントを外部システムと同期させるために使用します。外部システムがなく、他の状態に基づいて何らかの状態を調整したいだけの場合は、Effectが必要ないかもしれません。
ステップ2: Effectの依存関係を指定する
デフォルトでは、Effectはすべてのレンダーの後に実行されます。多くの場合、これは望ましくありません:
- 時には、遅すぎます。外部システムとの同期は常に瞬時に行えるわけではないため、必要な場合以外はスキップしたいことがあります。例えば、キーストロークのたびにチャットサーバーに再接続したくはありません。
- 時には、間違っています。例えば、キーストロークのたびにコンポーネントのフェードインアニメーションをトリガーしたくはありません。アニメーションは、コンポーネントが初めて表示されるときに一度だけ再生されるべきです。
この問題を示すために、前の例にいくつかのconsole.log呼び出しと、親コンポーネントの状態を更新するテキスト入力欄を追加しました。入力するとEffectが再実行されることに注目してください:
ReactにEffectの不要な再実行をスキップするよう指示するには、依存関係の配列をuseEffect呼び出しの第2引数として指定します。まず、上記の例の14行目に空の[]配列を追加してみましょう:
次のようなエラーが表示されるはずです:React Hook useEffect has a missing dependency: 'isPlaying'(React Hook useEffectに依存関係'isPlaying'が不足しています)
問題は、Effect内のコードが何を行うかを決定するために依存しているプロップisPlayingが、明示的に宣言されていないことです。この問題を修正するには、依存関係配列にisPlayingを追加します:
これですべての依存関係が宣言されたので、エラーはなくなります。[isPlaying]を依存関係配列として指定することで、ReactはisPlayingが前回のレンダー時と同じ場合にEffectの再実行をスキップするようになります。この変更により、入力欄への入力ではEffectは再実行されませんが、再生/一時停止ボタンを押すと実行されます:
依存配列には複数の依存関係を含めることができます。Reactは、指定した依存関係のすべてが、前回のレンダー時とまったく同じ値である場合にのみ、Effectの再実行をスキップします。Reactは依存関係の値をObject.is比較を使用して比較します。詳細についてはuseEffectリファレンスを参照してください。
依存関係を「選択」することはできないことに注意してください。指定した依存関係が、Effect内のコードに基づいてReactが期待するものと一致しない場合、lintエラーが発生します。これはコード内の多くのバグを捕捉するのに役立ちます。一部のコードを再実行したくない場合は、Effectコード自体を編集して、その依存関係を「必要としない」ようにしてください。
落とし穴
依存配列がない場合と、空の[]依存配列の場合の動作は異なります:
「マウント」の意味については次のステップで詳しく見ていきます。
ステップ3: 必要に応じてクリーンアップを追加する
別の例を考えてみましょう。ChatRoomコンポーネントを作成していて、それが表示されたときにチャットサーバーに接続する必要があるとします。createConnection()APIが与えられており、connect()メソッドとdisconnect()メソッドを持つオブジェクトを返します。コンポーネントがユーザーに表示されている間、どのように接続を維持しますか?
まずEffectのロジックを書きます:
再レンダーごとにチャットに接続するのは遅いので、依存配列を追加します:
Effect内のコードはpropsやstateを使用しないため、依存配列は[](空)です。これは、コンポーネントが「マウント」されたとき、つまり画面に初めて表示されたときにのみこのコードを実行するようにReactに指示します。
このコードを実行してみましょう:
このEffectはマウント時のみ実行されるため、コンソールに"✅ Connecting..."が一度だけ表示されると予想するかもしれません。しかし、コンソールを確認すると、"✅ Connecting..."が2回表示されています。なぜこのようなことが起こるのでしょうか?
例えば、ChatRoomコンポーネントが、多くの異なる画面を持つ大規模なアプリの一部であると想像してください。ユーザーはChatRoomページから操作を開始します。コンポーネントがマウントされ、connection.connect()が呼び出されます。次に、ユーザーが別の画面、例えば設定ページに移動したとします。するとChatRoomコンポーネントはアンマウントされます。最後に、ユーザーが戻るボタンをクリックし、ChatRoomが再びマウントされます。これにより2つ目の接続が確立されますが、最初の接続は破棄されていません!ユーザーがアプリ内を移動するにつれて、接続が積み重なっていくことになります。
このようなバグは、十分な手動テストを行わないと見落としがちです。これを素早く発見できるようにするため、開発環境ではReactが初期マウント直後にすべてのコンポーネントを一度再マウントします。
コンソールログ"✅ Connecting..."が2回表示されることで、実際の問題に気づくことができます:コンポーネントがアンマウントされたときに接続を閉じていないのです。
この問題を修正するには、Effectからクリーンアップ関数を返します:
Reactは、Effectが再度実行される前、およびコンポーネントがアンマウント(削除)される最終時に、毎回このクリーンアップ関数を呼び出します。クリーンアップ関数が実装された場合の動作を見てみましょう:
これで、開発環境では3つのコンソールログが表示されます:
"✅ Connecting...""❌ Disconnected.""✅ Connecting..."
これは開発環境における正しい動作です。コンポーネントを再マウントすることで、Reactは離脱して戻ってもコードが壊れないことを確認します。切断して再接続するというのは、まさに起こるべきことなのです!クリーンアップを適切に実装すれば、Effectを一度実行する場合と、実行→クリーンアップ→再実行する場合とで、ユーザーから見える違いはないはずです。追加の接続/切断の呼び出しペアがあるのは、Reactが開発環境でバグを探しているためです。これは正常なことで、なくそうとしないでください!
本番環境では、"✅ Connecting..."が一度だけ表示されます。コンポーネントの再マウントは、クリーンアップが必要なEffectを見つけるために開発環境でのみ発生します。Strict Modeをオフにして開発時の動作を無効にすることもできますが、オンにしておくことをお勧めします。これにより、上記のような多くのバグを見つけることができます。
開発環境でEffectが2回発火する場合の対処法
Reactは、前の例のようなバグを見つけるために、開発環境であえてコンポーネントを再マウントします。正しい問いは「Effectを一度だけ実行するにはどうするか」ではなく、「Effectを修正して、再マウント後も機能するようにするにはどうするか」です。
通常、答えはクリーンアップ関数を実装することです。クリーンアップ関数は、Effectが行っていたことを停止または元に戻すべきです。経験則として、ユーザーはEffectが一度実行される場合(本番環境のように)と、セットアップ → クリーンアップ → セットアップのシーケンス(開発環境で見られるように)とを区別できないはずです。
これから書くEffectのほとんどは、以下の一般的なパターンのいずれかに当てはまるでしょう。
落とし穴
refを使ってEffectの発火を防がないでください
開発環境でEffectが2回発火するのを防ぐためのよくある落とし穴は、refを使ってEffectが複数回実行されないようにすることです。例えば、上記のバグをuseRefで「修正」しようとするかもしれません:
これにより、開発環境で"✅ Connecting..."が一度だけ表示されるようになりますが、バグは修正されません。
ユーザーが離脱しても接続は閉じられず、戻ってきたときに新しい接続が作成されます。ユーザーがアプリ内を移動するにつれて、接続は「修正」前と同じように積み上がり続けるでしょう。
バグを修正するには、Effectを一度だけ実行するだけでは不十分です。Effectは再マウント後も機能する必要があり、つまり上記の解決策のように接続をクリーンアップする必要があります。
一般的なパターンの扱い方は、以下の例を参照してください。
非Reactウィジェットの制御
時には、Reactで書かれていないUIウィジェットを追加する必要があります。例えば、ページに地図コンポーネントを追加するとしましょう。それにはsetZoomLevel()メソッドがあり、ズームレベルをReactコード内のzoomLevel状態変数と同期させたいとします。その場合、Effectは次のようになります:
この場合、クリーンアップは必要ありません。開発環境では、ReactはEffectを2回呼び出しますが、同じ値でsetZoomLevelを2回呼び出しても何も起こらないため、問題にはなりません。わずかに遅くなるかもしれませんが、本番環境では不必要に再マウントされることはないため、これは問題ありません。
一部のAPIは、連続して2回呼び出すことができない場合があります。例えば、組み込みのshowModalメソッドは、2回呼び出すとエラーをスローします。クリーンアップ関数を実装し、ダイアログを閉じるようにしてください:
開発環境では、EffectはshowModal()を呼び出し、直後にclose()を呼び出し、そして再度showModal()を呼び出します。これは、本番環境で見られるようにshowModal()を1回呼び出すのと同じユーザー視覚的な動作になります。
イベントの購読
Effectが何かを購読している場合、クリーンアップ関数はその購読を解除する必要があります:
開発環境では、EffectはaddEventListener()を呼び出し、直後にremoveEventListener()を呼び出し、そして同じハンドラーで再度addEventListener()を呼び出します。そのため、一度にアクティブな購読は1つだけになります。これは、本番環境と同様にaddEventListener()を1回呼び出すのと同じユーザー視覚的な動作になります。
アニメーションのトリガー
Effectが何かをアニメーションで表示する場合、クリーンアップ関数はアニメーションを初期値にリセットする必要があります:
開発環境では、不透明度は1に設定され、次に0に設定され、そして再度1に設定されます。これは、本番環境で起こるように、直接1に設定するのと同じユーザー視覚的な動作になるはずです。トゥイーニングをサポートするサードパーティのアニメーションライブラリを使用する場合、クリーンアップ関数はタイムラインを初期状態にリセットする必要があります。
データの取得
Effectが何かを取得する場合、クリーンアップ関数はフェッチを中止するか、その結果を無視する必要があります:
既に発生したネットワークリクエストを「取り消す」ことはできませんが、クリーンアップ関数は、もはや関連性のないフェッチがアプリケーションに影響を与え続けないようにする必要があります。userIdが'Alice'から'Bob'に変更された場合、クリーンアップにより、'Alice'の応答が'Bob'の後に到着したとしても無視されることが保証されます。
開発環境では、ネットワークタブに2つのフェッチが表示されます。これには何の問題もありません。上記のアプローチでは、最初のEffectは直ちにクリーンアップされるため、そのignore変数のコピーはtrueに設定されます。そのため、余分なリクエストがあったとしても、if (!ignore)チェックのおかげで状態に影響を与えることはありません。
本番環境では、リクエストは1つだけになります。開発環境での2番目のリクエストが気になる場合は、リクエストを重複排除し、コンポーネント間で応答をキャッシュするソリューションを使用するのが最善のアプローチです:
これは開発体験を向上させるだけでなく、アプリケーションをより速く感じさせることにもなります。例えば、ユーザーが戻るボタンを押しても、データがキャッシュされているため、
アナリティクスの送信
ページ訪問時にアナリティクスイベントを送信する次のコードを考えてみましょう:
開発環境では、logVisitはURLごとに2回呼び出されるため、それを修正しようとするかもしれません。このコードはそのままにしておくことをお勧めします。前述の例と同様に、1回実行する場合と2回実行する場合では、ユーザーから見える動作の違いはありません。実用的な観点から、logVisitは開発環境では何もしないべきです。なぜなら、開発マシンからのログが本番環境のメトリクスを歪めることを望まないからです。コンポーネントのファイルを保存するたびにコンポーネントは再マウントされるため、開発環境ではとにかく余分な訪問ログが記録されます。
本番環境では、重複した訪問ログは記録されません。
送信している分析イベントをデバッグするには、アプリをステージング環境(本番モードで実行)にデプロイするか、一時的にStrict Modeとその開発専用の再マウントチェックをオプトアウトすることができます。また、Effectの代わりにルート変更イベントハンドラから分析データを送信することもできます。より正確な分析のためには、交差オブザーバーを使用して、どのコンポーネントがビューポート内にあるか、そしてそれらがどのくらいの間表示されているかを追跡することができます。
Effectではないもの: アプリケーションの初期化
一部のロジックは、アプリケーションが起動したときに一度だけ実行されるべきです。そのようなロジックはコンポーネントの外に置くことができます:
これにより、ブラウザがページを読み込んだ後にそのようなロジックが一度だけ実行されることが保証されます。
Effectではないもの: 商品の購入
クリーンアップ関数を書いたとしても、Effectを2回実行することによるユーザーから見える結果を防ぐ方法がない場合があります。例えば、Effectが商品の購入のようなPOSTリクエストを送信する場合です:
商品を2回購入したくはないでしょう。しかし、これがまさにこのロジックをEffectに置くべきではない理由です。ユーザーが別のページに移動してから戻るボタンを押したらどうなりますか?Effectは再び実行されます。ユーザーがページを訪問したときに商品を購入したいのではなく、ユーザーが購入ボタンをクリックしたときに購入したいのです。
購入はレンダリングによって引き起こされるのではなく、特定のインタラクションによって引き起こされます。それはユーザーがボタンを押したときにのみ実行されるべきです。Effectを削除し、/api/buyリクエストを購入ボタンのイベントハンドラに移動してください:
これは、再マウントによってアプリケーションのロジックが壊れる場合、通常は既存のバグが明らかになることを示しています。ユーザーの視点では、ページを訪問することと、ページを訪問してリンクをクリックし、戻るボタンを押してページを再度表示することは異なるべきではありません。Reactは、開発時にコンポーネントを一度再マウントすることで、コンポーネントがこの原則に従っていることを確認します。
まとめ
このプレイグラウンドは、Effectが実際にどのように動作するかを「体感」するのに役立ちます。
この例では、setTimeoutを使用して、Effectが実行されてから3秒後にコンソールに入力テキストをログ出力するようにスケジュールしています。クリーンアップ関数は保留中のタイムアウトをキャンセルします。まず「コンポーネントをマウント」ボタンを押してください:
最初に3つのログが表示されます:Schedule "a" log、Cancel "a" log、そして再度Schedule "a" logです。3秒後にはaというログも表示されます。先に学んだように、余分なスケジュール/キャンセルのペアは、Reactが開発時にコンポーネントを一度再マウントして、クリーンアップが適切に実装されていることを確認するためです。
次に入力欄を編集してabcと入力してください。十分に速く入力すると、Schedule "ab" logがすぐに表示され、その後にCancel "ab" logとSchedule "abc" logが続きます。Reactは常に、次のレンダーのEffectの前に、前のレンダーのEffectをクリーンアップします。これが、入力欄に速く入力しても、一度にスケジュールされるタイムアウトは最大で1つだけである理由です。入力欄を何度か編集して、コンソールを観察し、Effectがどのようにクリーンアップされるかを体感してください。
入力欄に何かを入力し、すぐに「コンポーネントをアンマウント」ボタンを押してください。アンマウントが最後のレンダーのEffectをクリーンアップする様子を確認してください。ここでは、最後のタイムアウトが発火する前にクリアされます。
最後に、上記のコンポーネントを編集し、クリーンアップ関数をコメントアウトして、タイムアウトがキャンセルされないようにしてください。abcdeと速く入力してみてください。3秒後に何が起こると予想しますか?タイムアウト内のconsole.log(text)は最新のtextを出力し、5つのabcdeログを生成しますか?直感を確かめるために試してみてください!
3秒後、一連のログ(a、ab、abc、abcd、そしてabcde)が表示され、5つのabcdeログにはなりません。各Effectは、対応するレンダー時のtext値を「捕捉」します。stateのtextが変更されたとしても、text = 'ab'のレンダーからのEffectは常に'ab'を見ます。言い換えれば、各レンダーからのEffectは互いに独立しています。これがどのように
まとめ
- イベントとは異なり、エフェクトは特定のインタラクションではなく、レンダリング自体によって引き起こされます。
- エフェクトを使用すると、コンポーネントを外部システム(サードパーティAPI、ネットワークなど)と同期させることができます。
- デフォルトでは、エフェクトはすべてのレンダリング(初回を含む)後に実行されます。
- すべての依存関係の値が前回のレンダリング時と同じ場合、Reactはエフェクトをスキップします。
- 依存関係を「選択」することはできません。それらはエフェクト内のコードによって決定されます。
- 空の依存配列(
[])は、コンポーネントが「マウント」される、つまり画面に追加される状態に対応します。 - Strict Modeでは、Reactはエフェクトをストレステストするために(開発時のみ!)コンポーネントを2回マウントします。
- 再マウントによってエフェクトが壊れる場合は、クリーンアップ関数を実装する必要があります。
- Reactは、次にエフェクトが実行される前と、アンマウント時にクリーンアップ関数を呼び出します。
Try out some challenges
Challenge 1 of 4:Focus a field on mount #
In this example, the form renders a <MyInput /> component.
Use the input’s focus() method to make MyInput automatically focus when it appears on the screen. There is already a commented out implementation, but it doesn’t quite work. Figure out why it doesn’t work, and fix it. (If you’re familiar with the autoFocus attribute, pretend that it does not exist: we are reimplementing the same functionality from scratch.)
To verify that your solution works, press “Show form” and verify that the input receives focus (becomes highlighted and the cursor is placed inside). Press “Hide form” and “Show form” again. Verify the input is highlighted again.
MyInput should only focus on mount rather than after every render. To verify that the behavior is right, press “Show form” and then repeatedly press the “Make it uppercase” checkbox. Clicking the checkbox should not focus the input above it.
