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