React 18でコンポーネントをはじめて表示(マウント)したときに、useEffect
が2度実行されるようになりました。
結論
React 18からの仕様です。StrictMode
が有効な開発時のみ、すべてのコンポーネントについてuseEffect
はこのように動作します。
なぜそのような仕様が採り入れられたのか、それで何の役に立つのか、公式「React Docs」(BETA)の「Synchronizing with Effects」の中に解説があります。その内容をかいつまんでご説明しましょう。なお、このページの情報全体は「React: エフェクトによる同期 ー useEffectのあれこれ」でご紹介しました。詳しくは、この記事をお読みください。
チャットのコンポーネントのコード例
ChatRoomコンポーネント
を書いて、それが表示(マウント)されたときチャットサーバーに接続したいとします。カスタムフックのuseConnection()
が返すのは、メソッドconnect()
とdisconnect()
を備えたオブジェクトです。ユーザーがコンポーネントを表示している間接続しつづけましょう。
まずは、エフェクト(useEffect
で定める副作用)のロジックから書き始めます。
const { connect, disconnect } = useConnection();
useEffect(() => {
connect();
}, []);
副作用のコードはプロパティも状態も参照していません。したがって、依存配列は空[]
です。つまり、Reactがこのエフェクトを実行するのは、コンポーネントが「マウント」されたときだけになります。それは、コンポーネントがはじめて画面に表示されたときです。
そこで、つぎのコードを試してみます。
コード001■useConnectionとChatRoomモジュール
type Hooks = {
connect: () => void;
disconnect: () => void;
};
export const useConnection = (): Hooks => {
// 実際にはサーバーへの接続と切断を実装する。
const connect = () => {
console.log('Connecting...');
};
const disconnect = () => {
console.log('Disconnected.');
};
return { connect, disconnect };
};
import { FC, useEffect } from 'react';
import { useConnection } from './useConnection';
export const ChatRoom: FC = () => {
const { connect, disconnect } = useConnection();
useEffect(() => {
connect();
}, []); // マウント時にのみ実行
return (
<div className="App">
<h1>Welcome to the chat!</h1>
</div>
);
};
このエフェクトはマウントでのみ実行されるため、コンソールには「Connecting...」と1回だけ出力されると予想したかもしれません。ところが、コンソールを確かめると、同じ出力が2回行われるのです。これはなぜでしょうか。
- (2) Connecting...
ChatRoom
コンポーネントが大きなアプリケーションの一部で、多くの異なる画面が備わっていたとします。ユーザーがはじめに開くのはChatRoom
のページです。コンポーネントはマウントされて、副作用のconnect()
が呼び出されます。そのあと、別の画面たとえば設定ページに移ったとしましょう。ChatRoom
コンポーネントがアンマウントされます。 そのうえで、ユーザーが[戻る]をクリックしたら、ChatRoom
はふたたびマウントされるのです。すると、2度目の接続が設定されます。けれど、はじめの接続が破棄されていません。ユーザーがアプリケーション内のページを移動することにより、接続がたまってしまうのです。
こうしたバグは、手作業による広範なテストを行わないかぎり、見逃されやすくなります。問題がすばやく見つけられるように、Reactは開発時にはすべてのコンポーネントをはじめのマウントの直後に再マウントするのです。「Connecting...」のログを2回見ることで、本当の問題に気づきやすくなります。コンポーネントがアンマウントされたとき、コードは接続を閉じていないということです。
この問題を解決するには、useEffect
からクリーンアップ関数を返してください。
useEffect(() => {
connect();
return () => {
disconnect();
};
}, []);
Reactがクリーンアップ関数を呼び出すのは、つねにエフェクトが再実行される前です。そして、最後にコンポーネントがアンマウント(削除)されるときに、クリーンアップを行います。書き替えたChatRoom
コンポーネントの記述全体は、つぎのコード002のとおりです。実際の動きについては、サンプル001のCodeSandbox作例をお確かめください。
コード002■ChatRoomモジュール
import { FC, useEffect } from 'react';
import { useConnection } from './useConnection';
export const ChatRoom: FC = () => {
const { connect, disconnect } = useConnection();
useEffect(() => {
connect();
return () => {
disconnect();
};
}, []);
return (
<div className="App">
<h1>Welcome to the chat!</h1>
</div>
);
};
サンプル001■React + TypeScript: Effect firing twice in development
開発時のコンソールには、つぎの3つの出力が示されるでしょう。
- Connecting...
- Disconnected.
- Connecting...
これは開発時における正しい動作です。コンポーネントの再マウントにより、Reactはナビゲーションで遷移して戻っても、コードが破綻しないことを確かめます。切断したのち再接続することは、まさに適切な動作です。クリーンアップが正しく実装されていれば、ユーザーから見て1度だけエフェクトを実行することと、そのあとクリーンアップしてから再実行することに差はありません。接続・再接続の呼び出しを1回余分に行うのは、Reactがコードにバグがないか検証するためです。これは正常なことで、除こうとするべきではありません。
開発ではなく本番環境では「Connecting...」の出力は1度だけです。コンポーネントの再マウントは、開発時のみクリーンアップが必要なエフェクトを見つけられるように行われます。StrictMode
をオフにすれば、開発時のこの機能は働きません。けれど、オンにしておくことをお勧めします。前述のような多くのバグを見つけることができるからです。
開発時に2度実行されるエフェクトを扱うヒント
つまり、考えなければならないのは、「エフェクトの実行を1度だけにする方法」ではありません。「エフェクトが再マウントされたあともどうやって正しく動作させるか」です。
通常は、クリーンアップ関数を実装して対応します。クリーンアップが行うのは、エフェクトの実行していたことをすべて停止あるいはもとに戻すことです。大抵のユーザーは、(本番環境の)1度だけのエフェクトと、(開発時の)副作用→クリーンアップ→副作用の実行の違いには気づきません。
開発時に2度実行されるエフェクトを扱うときのヒントについて少し補いましょう。
データの読み込み
エフェクトで実行するのがデータの読み込みのときは、クリーンアップ関数が行うのは読み込みの中断か、結果の無視です。
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);
すでに発信したネットワークリクエストは、なかったことにはできません。けれど、クリーンアップは、もう関係なくなったデータの受信が、そのあとアプリケーションに影響を与えないようにすべきです。このコード例では、userId
が変わったらクリーンアップが前のレスポンスは無視して、副作用により新たなuserId
でデータを取得します。
ただし、開発環境ではふたつのデータ取得が実行されるはずです。けれど、このコード例では問題ありません。はじめのエフェクトには、ただちにクリーンアップがかかり、関数の外の変数ignore
にtrue
が与えられます。レスポンス(json
)は受け取っても、値の条件判定によりその先の処理(setTodos()
)には渡されないからです。
もちろん、本番環境ではリクエストははじめにひとつしか送られません。
分析の送信
ページを訪問したときに分析のイベントを送るつぎのコードについて考えてみましょう。
useEffect(() => {
logVisit(url); // POSTリクエストを送る。
}, [url]);
開発段階では、logVisit
ははじめ同じURLに対して2度呼ばれます。これは避けたいと感じるかもしれません。けれど、コードはそのままにしておくのがよいでしょう。前項のコード例と同じように、ユーザーから見た動きは、実行が1回だろうと2回だろうと違わないからです。実践的な場面では、logVisit
は開発時には何もしないでしょう。開発用マシンからのログが、本番用のメトリクスを歪めてしまうことは避けたいからです。開発時、コンポーネントはファイルを保存するたびに再マウントされます。余計な訪問が送信されたからといって気にはならないでしょう。
本番環境では訪問ログが再送信されることはないのです。
送信される分析イベントのデバッグは、アプリケーションをステージング環境(本番モードで実行)にデプロイすればできます。あるいは、StrictMode
は一時的にオプトアウトして、開発専用の再マウントチェクをしてもよいでしょう。エフェクトに替えて、ルート変更イベントハンドラから分析を送信する手もあります。さらに正確な分析をするために使えるのが、交差オブザーバー(intersection observers)です。どのコンポーネントがビューポートにあり、どれくらいの間表示されているか調べるのに役立ちます。
「React: エフェクトによる同期 ー useEffectのあれこれ」の「開発時に2度実行されるエフェクトの扱い方」には、さらに多くの例をご紹介しました。ご興味がありましたらお読みください。