Effectで同期する
一部のコンポーネントは外部システムと同期する必要があるかもしれません。例えば、Reactの状態(state)とは関係のないサーバー接続や分析ログの送信など、コンポーネントが画面に表示された後に制御する場合です。
Effectsはレンダリング後にReact外部のシステムとコンポーネントを同期させることができます。
Effectsとは?イベントとの違いは?
Effectsについて知る前に、Reactコンポーネント内の2つのタイプのロジックについて理解する必要があります。
- レンダリングコードは純粋関数でなければなりません。
- イベントハンドラは単純な計算目的の関数ではなく、何かを行うコンポーネントのネストされた関数です。フィールドを更新したり、HTTPリクエストを送信したり、別の画面に移動したりできます。つまり、イベントハンドラはユーザーの操作によって発生する副作用を含みます。
上記の2つのロジックだけでは十分でない場合があります。例えば、画面に表示されるときにチャットサーバーと必ず接続する必要があるチャットルーム
コンポーネントを考えてみましょう。サーバーとの接続は純粋関数ではありません(副作用です)。そのため、レンダリング中に発生することはできません。
Effectsは特定のイベントではなく、レンダリング自体によって副作用を引き起こすことを可能にします。 チャットでメッセージを送信することはイベントです。なぜなら、ユーザーが特定のボタンをクリックすることで発生するからです。しかし、サーバー接続はEffectです。なぜなら、インタラクションに関係なく、コンポーネントが表示された瞬間に発生する必要があるからです。Effectsは画面の更新後、コミットが終了したときに実行されます。これはReactコンポーネントと外部システムを同期させるのに良いタイミングです。
Note
ここまでの「Effect」はReactで特化された定義を表し、すぐにレンダリングによる副作用を意味します。より一般的なプログラミングでの概念に言及するときは「副作用」と書きます。
あなたはEffectが必要ないかもしれません
コンポーネントに無条件にEffectsを追加しないでください。 Effectsは主にReactコードから離れて外部システムと同期するために使用されることを覚えておいてください。ここでの外部システムとは、ブラウザAPIやサードパーティウィジェット、ネットワークなどを指します。もし行いたいEffectが単に別の状態(state)に基づいたものであれば、Effectは必要ないかもしれません。
Effectを作成する方法
Effectを作成するには、以下の3つのステップに従ってください。
- Effectを宣言する。 デフォルトでは、Effectはすべてのコミット後に実行されます。
- Effect依存関係を指定する。 ほとんどのEffectはすべてのレンダリング後ではなく、必要なときだけ再実行されるべきです。
- 必要に応じてクリーンアップ関数を追加する。 一部のEffectは再レンダリング時に停止、元に戻す、クリーンアップする方法を指定する必要があります。例えば、接続した場合は接続を解除する必要があり、サブスクライブした場合はサブスクリプションを解除する必要があります。
ステップ1:Effectを定義する
コンポーネントでEffectを定義するには、useEffect
Hookを使用します。
コンポーネントの最上位レベルで呼び出し、内部にコードを配置します。
function MyComponent() {
useEffect(() => {
// ここのコードは*毎回の*レンダリング後に実行されます
});
return <div />;
}
コンポーネントがレンダリングされるたびに、Reactは画面を更新し、useEffect
内のコードを実行します。つまり、useEffect
はレンダリングが画面に反映されるまでコードの実行を遅延させます。
VideoPlayer
コンポーネントを例に見てみましょう。
function VideoPlayer({ src, isPlaying }) {
// TODO: isPlayingで何かをする
return <video src={src} />;
}
ブラウザの<video>
タグにはisPlaying
のような属性がないため、これを制御する方法はDOM要素で手動でplay()
やpause()
メソッドを実行することです。このコンポーネントでは、動画が現在再生中かどうかを示すisPlaying propの値をplay()
およびpause()
などの呼び出しと同期させる必要があります。
これを行うためにrefを取得してレンダリング中に呼び出そうとするかもしれませんが、これは正しいアプローチではありません。
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
if (isPlaying) {
ref.current.play(); // レンダリング中にこれらを呼び出すことは許可されていません。
} else {
ref.current.pause(); // また、これはクラッシュします。
}
return <video ref={ref} src={src} loop playsInline />;
}
このコードが正しくない理由は、レンダリング中にDOMノードと何かをしようとしているからです。Reactでは、レンダリングは純粋関数でなければならず、DOM操作のような副作用が発生してはいけません。
さらに、VideoPlayer
が初めて呼び出されるとき、該当するDOMはまだ存在しません。play()
やpause()
を実行するノードがないのです。
解決策はこれらの副作用をレンダリング計算から分離するためにuseEffect
でラップすることです。
import { useEffect, useRef } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => { // +
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}); // +
return <video ref={ref} src={src} loop playsInline />;
}
DOM更新をEffectでラップすると、まずReactが画面を更新し、その後Effectが実行されます。
Warning
デフォルトでは、Effectはすべてのレンダリング後に実行されます。これが次のコードが無限ループを作成する理由です。
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
状態が変わるとコンポーネントが再レンダリングされ、Effectが再度実行され、その結果、再び状態が変わり、このように続きます。
ステップ2:Effect依存関係を指定する
デフォルトでは、Effectはすべてのレンダリング後に実行されます。しかし、これは多くの場合望ましくありません。
- 外部システムとの同期は常に即時に行われるわけではないため、必要でない場合は操作をスキップしたい場合があります。例えば、すべてのキー入力でサーバーに再接続したくはないでしょう。
- すべてのキー入力でコンポーネントがフェードインアニメーションをトリガーすることは望ましくありません。そのようなアニメーションはコンポーネントが最初に表示されるときに一度だけ実行されることを望みます。
useEffect(() => {
if (isPlaying) { // ここで使用されています...
// ...
} else {
// ...
}
}, [isPlaying]); // ...だからここで宣言する必要があります!
依存配列として[isPlaying]
を指定すると、前回のレンダリング中にisPlaying
が前回と同じであれば、Effectを再実行しないようにReactに指示します。
依存配列には複数の依存関係を含めることができます。Reactは、指定したすべての依存関係の値が前回のレンダリング時の値と完全に一致する場合にのみ、Effectの再実行をスキップします。Reactは依存関係の値をObject.is
比較を使用して比較します。
依存関係を選択できないことを覚えておいてください。 指定した依存関係がEffect内部のコードに基づいてReactが期待するものと一致しない場合、lint errorが発生します。コードを再実行したくない場合は、Effect内部を修正して「依存関係が必要ないように」してください。
-
⚠️ 深掘り なぜrefは依存配列から省略できるのですか?
以下のコードでは、Effect内部で
ref
とisPlaying
の両方を使用していますが、依存関係にはisPlaying
のみが指定されています。
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);
これはref
が安定したアイデンティティを持っているからです。Reactは同じuseRef
呼び出しから常に同じオブジェクトを取得することを保証します。したがって、依存配列にref
を含めるかどうかは関係ありません。同様に、useState
から返されるset
関数も安定したアイデンティティを持っています。
ステップ3:必要に応じてクリーンアップを追加する
表示されるときにチャットサーバーと接続する必要があるChatRoom
コンポーネントを作成するとします。connect()
とdisconnect()
メソッドを持つオブジェクトを返すcreateConnection()
APIが提供されています。ユーザーに表示されている間、接続をどのように維持しますか?
useEffect(() => {
const connection = createConnection();
connection.connect(); // ✅ 接続中...
}, []);
Effect内部のコードはpropsやstateを使用していないため、空の依存配列([]
)を持ちます。これはEffectがコンポーネントが画面に最初に表示されるときにのみ実行されることを意味します。
このEffectはマウント時にのみ実行されるため、「✅ 接続中...」というコンソールが一度だけ表示されると予想するかもしれません。しかし、コンソールをチェックすると、2回表示されていることがわかります。
ページ上のChatRoom
コンポーネントがあり、別のページに移動してから戻ってくると考えてみてください。connect()
が再び呼び出され、2番目の接続が設定されますが、最初の接続は閉じられていません。
このようなバグは手動でテストしないと見逃しやすいため、Reactは開発モードで初期マウント後にすべてのコンポーネントを一度再マウントします。
この問題を解決するには、Effectからクリーンアップ関数を返します。
useEffect(() => {
const connection = createConnection();
connection.connect(); // ✅ 接続中...
return () => {
connection.disconnect(); // ❌ 切断しました。
};
}, []);
Reactは、Effectが再度実行される前にクリーンアップ関数を呼び出し、コンポーネントが削除されるときに最後に呼び出します。
開発環境では、以下の3つのログが表示されます:
"✅ 接続中..."
"❌ 切断しました。"
"✅ 接続中..."
これは開発モードでの正しい動作です。 コンポーネントを再マウントすることで、Reactは別の場所に移動して戻ってきてもコードが壊れないことを確認します。切断して接続することは、まさに起こるべきことです!Reactが開発中にコンポーネントを再マウントするとき、それはコードのバグをチェックしており、これは正常な動作なので、排除しようとしないでください。
本番環境では、"✅ 接続中..."
が一度だけ出力されます。
開発環境でEffectが2回実行される場合の対処法
上記の例のように、Reactは開発環境でバグを見つけるためにコンポーネントを再マウントします。ここでの正しい質問は「Effectを一度だけ実行する方法」ではなく、「再マウント後も正しく動作するようにEffectを修正する方法」です。
一般的な答えはクリーンアップ関数を実装することです。クリーンアップ関数はEffectが行っていた作業を停止または元に戻す必要があります。基本原則は、ユーザーが(本番環境のように)一度実行されるエフェクトと、(開発環境のように)実行、クリーンアップ、再実行されるエフェクトを区別できないようにすることです。
作成するほとんどのEffectは、以下の一般的なパターンのいずれかに該当します:
Reactで作成されていないウィジェットの制御
時々、Reactで作成されていないUIウィジェットを追加する必要があります。例えば、マップコンポーネントを追加する場合を考えてみましょう。マップコンポーネントにsetZoomLevel()
メソッドがあり、zoomLevel
状態と同期させたい場合、Effectは次のようになります:
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
この場合、クリーンアップは必要ありません。開発モードでは、Reactが2回Effectを呼び出してsetZomLevel()
を2回呼び出しますが、同じ値でsetZomLevel()
を2回呼び出しても何も起こりません。一部のAPIは連続して2回呼び出すことができない場合があります。例えば、<dialog>
のshowModal
メソッドの場合です。2回呼び出すと例外がスローされます。この場合、クリーンアップ関数を実装してダイアログを閉じるようにします。
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);
イベントのサブスクライブ
Effectで何かをサブスクライブする場合、クリーンアップ関数でサブスクリプションを解除する必要があります。
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
アニメーションのトリガー
Effectで要素にアニメーション効果を与える場合、クリーンアップ関数でアニメーションを初期値に設定する必要があります。
useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // アニメーションをトリガー
return () => {
node.style.opacity = 0; // 初期値にリセット
};
}, []);
データのフェッチ
Effectがデータをフェッチする場合、クリーンアップ関数ではフェッチを中止するか結果を無視する必要があります。
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);
すでに発生したネットワークリクエストを「キャンセル」することはできませんが、クリーンアップ関数は関連性のなくなったフェッチが状態に影響を与えないようにする必要があります。
開発環境では、ネットワークタブに2つのフェッチが表示されます。 上記のアプローチを使用すると、最初のEffectはすぐにクリーンアップされ、ignore
変数のコピーがtrue
に設定されるため、追加のリクエストがあってもif
チェックのおかげで状態に影響を与えません。
本番環境では、1つのリクエストだけが存在します。 開発環境での2番目のリクエストが気になる場合は、コンポーネント間でレスポンスをキャッシュするソリューションを使用するのが最良の方法です。
function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...
Effectでデータをフェッチする良い代替手段は?
Effectの中でfetch
を呼び出すことは、データを取得する最も人気のある方法です、特に完全にクライアントサイドのアプリケーションでは。しかし、これは非常に手動的なアプローチであり、重要な欠点があります:
- Effectはサーバーで実行されません。 すべてのJavaScriptをダウンロードしてアプリをレンダリングした後でないとデータをロードできないため、これは効率的ではありません。
- Effectの中で直接フェッチすると「ネットワークウォーターフォール」を簡単に作ることができます。 親コンポーネントをレンダリングし、いくつかのデータをフェッチし、子コンポーネントをレンダリングし、子コンポーネントが自分たちのデータのフェッチを開始します。ネットワークが速くない場合、これは並行してすべてのデータをフェッチするよりもはるかに遅くなります。
- Effectの中で直接フェッチすることは、プリロードやキャッシュされないことを意味します。 例えば、コンポーネントがアンマウントされて再マウントされると、データを再度フェッチする必要があります。
- それほど便利ではありません。 バグの影響を受けない方法でデータフェッチの実装を書くには、多くのボイラープレートコードが必要です。
これらの欠点はReactに特有のものではありません。任意のライブラリでマウント時にデータをフェッチする場合に適用されます。ルーティングと同様に、データフェッチはうまく行うのが簡単ではないため、次のアプローチを推奨します:
- フレームワークを使用している場合は、そのフレームワークの組み込みデータフェッチメカニズムを使用してください。
- そうでない場合は、React Query、useSWR、React Router 6.4+などのオープンソースソリューションの導入を検討してください。
分析の送信
ページ訪問時に分析情報を送信するコードを見てみましょう。
useEffect(() => {
logVisit(url); // POSTリクエストを送信
}, [url]);
開発環境では、logVisit
が各URLに対して2回呼び出されるため、修正したいと思うかもしれません。しかし、このままにしておくことをお勧めします。前の例と同様に、1回実行するか2回実行するかの間にユーザーが見える動作の違いはありません。実際には、開発環境ではlogVisit
は何も実行すべきではありません。なぜなら、開発データを収集して製品指標を歪めるべきではないからです。
本番環境では、重複した訪問ログはありません。
Effectではない場合:アプリケーションの初期化
一部のロジックはアプリケーションが起動するときに一度だけ実行する必要があります。このようなロジックはコンポーネントの外部に配置できます。
if (typeof window !== 'undefined') { // ブラウザで実行中かどうかを確認します。
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
このようにコンポーネント外部でロジックを実行すると、ページをロードした後に一度だけ実行されることが保証されます。