LoginSignup
1
1

React + TypeScript: エフェクトを使って同期する

Last updated at Posted at 2023-07-20

React公式サイトのドキュメントが2023年3月16日に改訂されました(「Introducing react.dev」参照)。本稿は、応用解説の「Synchronizing with Effects」をかいつまんでまとめた記事です。ただし、コードにはTypeScriptを加えました。反面、初心者向けのJavaScriptの基礎的な説明は省いています。

なお、本シリーズ解説の他の記事については「React + TypeScript: React公式ドキュメントの基本解説『Learn React』を学ぶ」をご参照ください。

コンポーネントには、外部システムと同期しなければならないものもあります。たとえば、つぎのような場合です。

  • React以外のコンポーネントを状態にもとづいて制御したいとき。
  • サーバーとの接続を確立したいとき。
  • 分析用のログをコンポーネントが画面に表示されたら送りたいとき。

エフェクトは、レンダリング後にそのコードを実行します。すると、Reactの外のシステムとコンポーネントを同期できるのです。

エフェクトとは ー イベントとの違い

エフェクトについてご説明する前に、Reactコンポーネント内のふたつのロジックについて理解しましょう。

  • コードのレンダリング: コンポーネントのトップレベルに存在します(「React + TypeScript: ユーザーインタフェースを組み立てる」参照)。プロパティや状態を受け取り、変換して、画面に表示すべきJSXを返すのがこの場所です。コードのレンダリングは純粋でなければなりません。数式と同じく、結果を導出するだけです。他のことは行いません。
  • イベントハンドラ: コンポーネントの中に含まれた関数です(「React + TypeScript: 発生したイベントを処理する」参照)。純粋な計算以外の処理も行います。たとえば、以下のような用途です。イベントハンドラは副作用(プログラムの状態変更)を含みます。実行を引き起こすのは、ユーザーの特定のアクション(たとえば、ボタンクリックやキーボード入力)です。
    • テキスト入力フィールドの更新。
    • 商品を購入するためのHTTP POSTリクエストの送信。
    • ユーザーの別画面への遷移。

けれども、このふたつだけでは十分でないこともあります。ChatRoomコンポーネントを考えてみましょう。画面に表示されたときは、つねにサーバーと接続していなければなりません。サーバーへの接続は純粋な計算ではなく(副作用をともなうため)、レンダリング中には行えないのです。かといって、ChatRoomを表示させるための、クリックのような特定のイベントもありません。

エフェクトは副作用を定めて、特定のイベントでなく、レンダリング自体により実行します。チャットでメッセージを送るのはイベントです。ユーザーが特定のボタンをクリックしたことにより引き起こされます。これに対して、サーバーとの接続の確立はエフェクトです。サーバー接続は、コンポーネントがどのようなインタラクションにより表示されたかにかかわりません。エフェクトの実行は、コミットの最後で、画面が更新されたあとです。Reactコンポーネントを外部システム(ネットワークやサードパーティのライブラリなど)と同期させるには、これが適したタイミングといえるでしょう。

[注記] 本稿のこのあとのご説明では、「エフェクト」はReact固有の上述の定義、つまりレンダリングにもとづく副作用を指します。広義のプログラミングの概念は「副作用」と呼びましょう。

エフェクトは要らない場合もある

エフェクトをすぐコンポーネントに加えようとするのは得策ではありません。通常、Reactから「外に出る」ために用いられるのがエフェクトです。たとえば、つぎのような外部システムと同期します。

  • ブラウザAPI。
  • サードパーティのウィジェット。
  • ネットワークなど。

エフェクトが、ある状態を他の状態にもとづいて調整しているだけなら、おそらく不要です

エフェクトの書き方

エフェクトは、つぎの3つの手順にしたがって書きます。

  1. エフェクトを宣言してください。デフォルトでは、エフェクトの実行は毎回のレンダーが終わったあとです。
  2. エフェクトの依存配列を指定します。ほとんどのエフェクトは、必要な場合にのみ再実行されるべきです。各レンダー後に処理する必要はありません。たとえば、フェードインアニメーションを開始するのは、コンポーネントが表示されたときでしょう。チャットルームに接続や切断されるのは、コンポーネントの表示・非表示、あるいはチャットルームが変わった場合です。依存配列を用いた制御の仕方については後述します。
  3. 必要に応じてクリーンアップを加えなければなりません。エフェクトによっては、やっていたことの停止や取り消し、あるいはクリーンアップの仕方を定める必要があります。たとえば、「接続」には「切断」、「登録」には「解除」が、「取得」には「キャンセル」または「無視」といったあと処理です。クリーンアップ関数は、エフェクトから返します。

3つの手順を具体的に見ていきましょう。

手順1: エフェクトを宣言する

コンポーネントにおけるエフェクトの宣言には、まずuseEffectフックをReactからimportしてください。つぎに、フックを呼び出すのは、コンポーネントのトップレベルです。引数のコールバック関数本体にコードを書き加えましょう。

import { useEffect } from 'react';

function MyComponent() {
	useEffect(() => {
		// 毎回のレンダーが終わったあと実行される
	});
	return <div />;
}

コンポーネントがレンダーされるたびに、Reactは画面を更新し、そのあとuseEffect内のコードは実行されます。言い換えれば、コードの実行をレンダーの画面への反映まで遅らせる」のがuseEffectです。

エフェクトを用いて、どう外部システムと同期するのか確かめましょう。ビデオを再生するコンポーネント<VideoPlayer>を考えてみます。コンポーネントに渡すプロパティisPlayingは、再生と停止をコントロールする論理値の状態変数です。

src/App.tsx
export default function App() {
	const [isPlaying, setIsPlaying] = useState(false);
	return (
		<>
			<button onClick={() => setIsPlaying(!isPlaying)}>
				{isPlaying ? 'Pause' : 'Play'}
			</button>
			<VideoPlayer
				isPlaying={isPlaying}
				src="ビデオファイルのパス"
			/>
		</>
	);
}

VideoPlayerコンポーネントは、ブラウザ組み込みの<video>要素をJSXで返します。

src/VideoPlayer.tsx
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {
	// isPlayingとビデオ再生を同期させる
	return <video src={src} />;
};

けれど、isPlaying<video>要素のプロパティではありません。ビデオを再生したり停止するには、DOM要素<video>play()pause()メソッドの呼び出しが必要です。つまり、ビデオの再生・停止を示す状態変数isPlayingは、play()およびpause()と同期しなければなりません。

そのために、まずはDOMノード<video>refを得ましょう。そうすれば、プロパティの値に応じて、<video>要素のplay()およびpause()メソッドが呼び出せるのです。

src/VideoPlayer.tsx
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {
	const ref = useRef<HTMLVideoElement>(null);
	console.log('ref:', ref.current); // 確認用
	// レンダー中にDOMノードを操作すべきではない
	if (isPlaying) {
		ref.current?.play();
	} else {
		ref.current?.pause();
	}
	return <video ref={ref} src={src} loop playsInline />;
};

これで、VideoPlayerコンポーネントのビデオが再生および停止できます。しかし、このコードは望ましくありません。確認用に加えたconsole.log()により、コンソールにはじめにつぎのような出力が示されるはずです。

ref: null

これは、VideoPlayerコンポーネントの最初のレンダー中でまだDOMは存在しないとき、ref.currentの操作がされようとしていることを意味します。Reactでは、レンダリングはJSXの純粋な計算でなければなりません。DOMの操作などの副作用は除いてください。

VideoPlayerがはじめて呼び出されるとき、DOMはまだ存在しません。ReactはJSXが返されるまで、どのようなDOMを作成すべきかわからないのです。

[注記] 公式サイトのコード例では、エラーが返されています。これは、ref.currentnullかどうかの判定を省いているからです。

解決するためには、副作用をuseEffectで包んで、レンダリングの計算処理から切り出しましょうconsole.log()からコンソールへのnullの出力はなくなるはずです(サンプル001)。

src/VideoPlayer.tsx
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {

	useEffect(() => {
		console.log("ref:", ref.current); // 確認用
		if (isPlaying) {
			ref.current?.play();
		} else {
			ref.current?.pause();
		}
	});

};

サンプル001■React + TypeScript: Synchronizing with Effects 01

DOMの変更はエフェクトに包まれました。すると、Reactは先に画面を更新します。エフェクトの実行はそのあとです。

<VideoPlayer>コンポーネントがレンダーされるとき(初回も再レンダーも含めて)、つぎのようなことが行われます。

  • まず、Reactによる画面の更新です。これで、DOMの中に<video>要素が正しいプロパティをもって備わります。
  • つぎに、Reactが実行するのはエフェクトです。
  • そのうえで、エフェクトはplay()またはpause()メソッドを、isPlayingの値に応じて呼び出します。

このコード例で、Reactの状態と同期させた「外部システム」は、ブラウザのメディアAPIでした。同様のやり方は、Reactでない従来のコード(jQueryプラグインなど)を、宣言的なReactコンポーネントに包むときも用いることができるでしょう。

なお、ビデオプレーヤの制御は実際にはもっと複雑です。play()の呼び出しは、失敗することもあります。ユーザが、ブラウザ組み込みのコントロールを使って、再生や一時停止するかもしれません。このコード例は、きわめて単純化した不完全なものとご承知ください。

エフェクトで状態を設定すると無限ループになる

デフォルトでは、エフェクトが実行されるのは毎回のレンダー後です。そのため、以下のコードは無限ループになります

Warning: Maximum update depth exceeded.

export default function App() {
	const [count, setCount] = useState(0);
	useEffect(() => {
		setCount(count + 1);
	});
	
}

エフェクトの実行は、レンダリングの結果です。状態を設定すれば、レンダリングが引き起こされます。ところが、上記コードはレンダリングの結果であるエフェクトの中で、状態を変更しているのです。それは、再びレンダリングを引き起こします。この繰り返しが無限ループです。

エフェクトは通常、コンポーネントを外部システムと同期させます。外部システムがなく、ある状態を他の状態に合わせたいだけなら、エフェクトは要らないかもしれません

手順2: エフェクトの依存配列を指定する

デフォルトでは、エフェクトは毎回のレンダーのあとに実行されます。それが、望ましくはないかもしれません。

  • 処理の遅れにつながります。外部システムとはすぐに同期できるとはかぎりません。必要なければ省いてしまってもよさそうです。たとえば、チャットサーバーに、キー入力するたびに接続し直したいとは考えないでしょう。
  • 毎回のレンダー後に実行するのが間違いの場合もあります。コンポーネントにフェードインアニメーションを定めたとしましょう。開始はキー入力ではなく、コンポーネントがはじめて表示された1回だけにすべきです。

前掲サンプル001は、useEffectフックに依存配列(第2引数)を定めませんでした。その場合の問題がわかるように、親コンポーネント(App)にテキスト入力フィールド(<input />)を加えましょう。入力したテキストは状態変数(text)に保持されます。エフェクトはテキストをタイプするたびに呼び出され、console.log()の出力が確かめられるはずです。

src/VideoPlayer.tsx
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {

	useEffect(() => {
		console.log("ref:", ref.current); // 確認用
		if (isPlaying) {
			ref.current?.play();
		} else {
			ref.current?.pause();
		}
	});

};
src/App.tsx
export default function App() {

	const [text, setText] = useState('');
	return (
		<>
			<input
				value={text}
				onChange={({ target: { value } }) => setText(value)}
			/>

		</>
	);
}

useEffectフックの第2引数に、エフェクトの依存値を収めて渡すのが依存配列です。依存値が変わらないかぎり、エフェクトは無駄に再実行されません。まず、以下のように空の配列[]を渡してみましょう。つまり、依存なしということです。エフェクトは、コンポーネントのはじめのレンダー時に実行されたあと、再実行されなくなります。

src/VideoPlayer.tsx
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {

	useEffect(() => {
		console.log("ref:", ref.current); // 確認用
		if (isPlaying) {
			ref.current?.play();
		} else {
			ref.current?.pause();
		}
	// });
	}, []);

};

ただし、テキスト入力だけでなく、ボタンを押しても何も起こらないでしょう。原因は、リンターがつぎのような警告で知らせてくれています。ボタンを押してisPlayingの値が変わっても、依存配列(dependency)に含まれていないので、useEffectは再実行されないということです。

React Hook useEffect has a missing dependency: 'isPlaying'. Either include it or remove the dependency array.

リンターの指示にしたがって、isPlayinguseEffectの依存配列(第2引数)に加えましょう(サンプル002)。これで、ボタンを押してisPlayingの値が前回のレンダー時から変わったとき以外、エフェクトは無駄に再実行されません。テキストフィールドに入力しても、エフェクトがtextには依存しないため、実行は省かれるのです。

src/VideoPlayer.tsx
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {

	useEffect(() => {

		if (isPlaying) { // 依存値

		} else {

		}
	//}, []);
	}, [isPlaying]); // 依存値は依存配列に含める

};

サンプル002■React + TypeScript: Synchronizing with Effects 02

依存配列には、複数の依存値が加えられます。Reactがエフェクトを再実行する前に確かめるのは、与えられたすべての依存値です。それらが前回のレンダー時とまったく同じであれば、エフェクトは再実行されません。このとき、Reactが依存値の比較に用いるのはObject.isです(「useEffect」の「リファレンス」参照)。

依存値は勝手に「選ぶ」ものではありません。与えた依存値が、エフェクト内のコードにもとづいてReactの期待する依存配列と合わない場合には、リンターから警告されます。こうして、コード内の多くのバグが検出できるのです。もし、エフェクトの中に再実行したくないコードがあるときは、「依存せずに済む」よう書き替えてください

useEffectの第2引数(依存配列)

useEffectの第2引数である依存配列は、指定により実行のされ方が異なります。なお、マウント時というのは、コンポーネントが表示されるときです。

useEffect(() => {
	// 毎回レンダーされたあとに実行される
}); // 第2引数なし

useEffect(() => {
	// マウント時のみ実行される
}, []); // 空の依存配列

useEffect(() => {
	// マウント時と、aかbの値が前回のレンダーから変わった場合に実行される
}, [a, b]); // 依存値を配列に加える

refは依存配列に含めなくてよい

前掲コード例のエフェクトでは、isPlayingだけでなくrefも参照していました。けれど、リンターはrefを依存配列に加えるようには求めません、

src/VideoPlayer.tsx
export const VideoPlayer: FC<Props> = ({ src, isPlaying }) => {

	useEffect(() => {
		if (isPlaying) {
			ref.current?.play();
		} else {
			ref.current?.pause();
		}
	}, [isPlaying]);

};

これは、refオブジェクトがつねに変わらず、同一だからです。Reactは、同じuseRefの呼び出しから得るオブジェクトが、レンダーごとにつねに同じであることを保証します。つまり、変化することがありません。エフェクトの再実行も引き起こさないのです。したがって、refは依存に含める必要はなく、ただし含めても問題はありません。

useStateが返す配列第2要素のset関数も、つねに変わらず、同一です。そのため、依存配列からはたびたび省かれます。依存値に含めなくてもリンターから警告を受けなければ、安全ですので、省略して構いません。

つねに同一のオブジェクトを依存から省けるのは、リンターがオブジェクトは変わらないと「判断できる」ときだけです。たとえば、refが親コンポーネントから渡される場合は、依存配列に加えなければなりません。親コンポーネントはつねに同じオブジェクトを渡すとはかぎらず、条件によって異なるrefが選ばれるかもしれないからです。つまり、エフェクトはどのrefを受け取るかに依存します。

手順3: 必要に応じてクリーンアップを加える

また別の例として、ChatRoomコンポーネントをつくり、表示されたらチャットサーバーに接続しなければならないとしましょう。createConnection()APIは、メソッドconnect()(接続)とdisconnect()(切断)が備わったオブジェクトを返します。コンポーネントがユーザに表示されている間、接続し続けるにはどうすればよいでしょうか。

まず、エフェクトのロジックから考えます。

useEffect(() => {
	const connection = createConnection();
	connection.connect();
});

レンダーのたびに、そのあとチャットへの接続を繰り返したら、負荷が高まるだけでしょう。そこで、加えるのが依存配列です。

useEffect(() => {
	const connection = createConnection();
	connection.connect();
}, []);

エフェクト内のコードはプロパティや状態を用いていません。したがって、依存配列は空[]です。すると Reactは、コンポーネントが「マウント」、つまり画面にはじめて表示されるときだけ、このコードを実行します(サンプル003)。

サンプル003■React + TypeScript: Synchronizing with Effects 03

console.log()は、createConnection()(モジュールchat.ts)が返すconnect()(✅ Connecting...)とdisconnect()(❌ Disconnected.)に確認用に確認用に加えました。コンソールの出力はつぎのとおりです。connect()が2回呼ばれていることを意味します。

✅ Connecting...
✅ Connecting...

これは、React 18の開発環境における仕様です(「React + TypeScript: React 18でコンポーネントのマウント時にuseEffectが2度実行されてしまう」)。ChatRoomコンポーネントが、さまざまな画面をもつ大きなアプリケーションの一部だと考えましょう。

  • ユーザーが、ChatRoomページから閲覧を始めました。
    • コンポーネントがマウントされ、connection.connect()が呼び出されます。
  • つぎに遷移したのは別の画面、たとえば設定ページです。
    • ChatRoomコンポーネントはアンマウントされます。
  • そのうえで、ユーザが戻るボタンをクリックしたらどうでしょう。
    • ChatRoomが再びマウントされ、設定されるのはふたつめの接続です。

ところが、ひとつめの接続は破棄されていません。ユーザがアプリケーション内を移動するたびに、接続がどんどん重複してしまうのです。

このようなバグは、手作業の入念なテストをしないかぎりなかなか見つけられません。その対処として、Reactは開発環境では、初回マウント直後にすべてのコンポーネントを一度だけ再マウントするのです。

"✅ Connecting..."のログが2回出力されたことにより、問題に気づけました。つまり、コンポーネントがアンマウントされたときに、接続を閉じるコードがありません。

コンポーネントがアンマウントされるときのあと処理は、クリーンアップ関数としてuseEffectから返してください(サンプル004)。

useEffect(() => {
	const connection = createConnection();
	connection.connect();
	return () => {
		connection.disconnect();
	};
}, []);

Reactは、エフェクトが再実行される前に毎回クリーンアップ関数を呼び出します。また、最後に1回呼び出されるのは、コンポーネントがアンマウントされる(画面から削除される)ときです。

サンプル004■React + TypeScript: Synchronizing with Effects 04

これで、開発環境ではコンソールに3つのログが出力されるようになりました。

✅ Connecting...
❌ Disconnected.
✅ Connecting...

この動作について、ふたつ補足します。

これは、開発環境における正しい動作です。コンポーネントを再マウントすることにより、Reactは他に遷移してから戻っても、コードが破綻しないことを確かめます。切断してから再接続するのは、まさに望むべき結果です。クリーンアップが正しく実装されれば、つぎのふたつにユーザーから見た違いはほとんどありません。

  • エフェクトを1度だけ実行。
  • エフェクト実行後クリーンアップしたうえで再実行。

接続/切断の呼び出しがひと組余分にあるだけです。これにより、開発時Reactはコードにバグが潜んでいないか探れます。正常な動きですので、排除しようとすべきではありません。

本番環境では、"✅ Connecting..."の出力は1回だけです。コンポーネントの再マウントは開発時のみ行われ、エフェクトにクリーンアップが必要かどうか確かめられます。開発時の動きを加えているのはStrictModeです。けれど、この設定は外さないでください。前述のようなさまざまなバグを見つけるのに役立つからです。

開発時に2度実行されるエフェクトを正しく扱う

Reactは開発時に、コンポーネントを意図的に再マウントします。それにより、前掲コード例のバグも見つけられました。したがって、どうしたらエフェクトの実行を1回にできるのかと問うのは正しくありません再マウントしても正しく動くためにエフェクトをどう修正するか考えるべきです

通常は、クリーンアップ関数を実行することにより解決できるでしょう。クリーンアップ関数は、エフェクトがやってきたことを停止もしくは取り消します。目安として、ユーザーがつぎのふたつの違いを意識せずに済むようにすることです。

  • エフェクトが1度実行された場合(本番環境と同じ)。
  • エフェクトの実行がクリーンアップされて再実行された場合(開発環境と同じ)。

開発時に2度実行されるエフェクトにどう対応すべきか考えるのは、つぎのような例がほとんどでしょう。

React以外のウィジェットを制御する

Reactで書かれていないUIウィジェットを加えたい場合があるかもしれません。たとえば、地図コンポーネントをページに差し込むとしましょう。メソッドsetZoomLevel()が備わり、その値はReactコードの状態変数zoomLevelと同期させなければなりません。エフェクトは、つぎのようになるでしょう。

useEffect(() => {
	const map = mapRef.current;
	map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

このエフェクトにクリーンアップは要りません。開発環境では、Reactはエフェクトを2回実行します。けれど、setZoomLevelを同じ値で2度呼び出したからといって、とくに何も起こらないからです。少し負荷が上がったとしても、本番環境では無駄な再マウントはされないのですから問題ありません。

APIによっては、続けざまに2度呼び出すことが許されない場合もあるでしょう。たとえば、組み込みの<dialog />要素のshowModalメソッドは、すでにダイアログが開いているのに呼び出せば例外を投げます。クリーンアップ関数を実装して、ダイアログは閉じなければならないのです。

useEffect(() => {
	const dialog = dialogRef.current;
	dialog.showModal();
	return () => dialog.close();
}, []);

開発環境では、エフェクトがはじめに呼び出すshowModal()は、ただちにclose()で閉じ、改めてshowModal()を実行します。ユーザーから見るかぎり、振る舞いは本番環境でshowModal()を1度だけ呼び出すのと変わりません。

イベントを検知する

エフェクトで、イベントなどを検知するリスナーに登録したときは、クリーンアップ関数で登録を削除してください。

useEffect(() => {
	function handleScroll(e) {
		console.log(window.scrollX, window.scrollY);
	}
	window.addEventListener('scroll', handleScroll);
	return () => window.removeEventListener('scroll', handleScroll);
}, []);

開発環境では、このエフェクトはaddEventListener()呼び出しののちに、ただちにremoveEventListener()で削除、改めて同じハンドラにaddEventListener()を実行しています。結果として、有効なリスナーはひとつだけです。ユーザーから見ても、本番環境でaddEventListener()を1度だけ呼び出す場合と同じ振る舞いになります。

アニメーションを開始する

エフェクトがアニメーションを実行したら、クリーンアップ関数はそれを初期値に戻さなければなりません。

useEffect(() => {
	const node = ref.current;
	node.style.opacity = 1; // アニメーションの開始
	return () => {
		node.style.opacity = 0; // 初期値に戻す
	};
}, []);

開発環境では、エフェクトが設定するopacityの値1は、クリーンアップで0に戻され、改めて1に再設定されます。ユーザーから見れば、直接値1を定める本番環境の振る舞いと変わるところがありません。なお、使うアニメーションのサードパーティライブラリがトゥイーンに対応する場合、クリーンアップ関数はタイムラインを初期状態に戻すことも必要です。

データを取得する

エフェクトで開始した外部データの取得は、クリーンアップ関数が中止するか、結果を無視しなければなりません。

useEffect(() => {
	let ignore = false;
	async function startFetching() {
		const json = await fetchTodos(userId);
		if (!ignore) {
			setTodos(json);
		}
	}
	startFetching();
	return () => {
		ignore = true;
	};
}, [userId]);

すでに行ったリクエストは「なし」にはできません。ですから、クリーンアップ関数は、もはや要らなくなった取得がアプリケーションに影響し続けないようにしなければならないのです。たとえば、userId'Alice'から'Bob'に変わったら、クリーンアップは'Alice'のレスポンスは'Bob' のあとに届いても無視しなければなりません。

開発環境では、デベロッパーツールの[ネットワーク]タブでふたつの取得が確かめられるでしょう。それで問題はありません。前掲コード例の対応により、はじめのエフェクトはただちにクリーンアップされ、変数ignoretrueに定められます。したがって、条件判定if (!ignore)で状態に影響を与えることが避けられるからです。結果として、あとのリクエストが有効になります。

本番環境では、リクエストは1度だけです。開発時のふたつめのリクエストが気になるかもしれません。そういう場合にもっとも適しているのは、リクエストが重複しないようにし、レスポンスはコンポーネント間でキャッシュする手法を用いることです。

function TodoList() {
	const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
	// ...
}

開発体験が高まるだけでなく、アプリケーションも高速化できるでしょう。たとえば、ユーザーが戻るボタンを押してもロード待ちは要りません。データがキャッシュされるので、再読み込みせずに済むからです。このようなキャッシュは、自作で構築することもできます。あるいは、エフェクトでの手動フェッチに替わるさまざまなライブラリから選んでもよいでしょう。

データをエフェクト以外で取得する手法

エフェクトからfetchを呼び出すのは、とくに完全にクライアントサイドのアプリケーションであれば、データの取得として一般的です。ただ、ほとんど手作業となり、つぎのような問題があります。

エフェクトはサーバー上では動作しません。つまり、サーバレンダリングされた初期HTMLにはデータがなく、ロード中の状態だけが示されるということです。クライアントのコンピュータは、JavaScriptをすべてダウンロードしなければなりません。そして、アプリケーションをレンダーして、ようやくつぎにデータをロードしなければならないと知るのです。効率的とはいいがたいでしょう。

エフェクトから直にデータを取得すると「ネットワークのウォーターフォール」ができがちです。親コンポーネントをレンダーして、データをフェッチしたとしましょう。つぎに、子コンポーネントをレンダーしたら、今度はそのデータの取得が始まるかもしれません。ネットワークが高速でなければ、すべてのデータを並行で取得するのと比べて速度は大幅に下がるでしょう。

エフェクトでじかにフェッチしているということは、大抵データをプリロードもキャッシュもしていません。それはたとえば、コンポーネントがアンマウントされたあとに再マウントされたら、データは再取得しなければならないということです。

コードは決して書きやすくありません。かなりのボイラープレートコードがないと、競合状態のようなバグを起こさないfetch呼び出しは書けないでしょう。

これらの問題はReactにかぎったことではありません。マウント時にデータを取得するなら、どのライブラリにでもいえることです。ルーティングと同じように、データのフェッチは正しく実装するのは簡単ではありません。お勧めするのは、つぎのふたつのやり方です。

  • フレームワークを使う場合: 組み込みのデータフェッチの機能を用いてください。モダンなReactフレームワークには、データフェッチの仕組みが統合されています。効率的かつ上述のような問題もありません。
  • フレームワークを使わない場合: クライアントサイドキャッシュの利用または構築をお考えください。よく用いられるオープンソースのソリューションとしては、React QueryuseSWR、およびReact Router 6.4+が挙げられます。ソリューションを自前で構築してもよいでしょう。その場合、内部的にエフェクトは使いつつ、つぎのようなロジックを加えてください。
    • リクエストの重複排除。
    • レスポンスのキャッシュ。
    • ネットワークのウォーターフォール回避。
      • データのプリロードやルーティングへのデータ要求の引き上げ。

これらの手法のどちらも目的に合わない場合には、エフェクト内で直接データを取得することにしましょう。

アナリティクスログの送信

つぎのコードが、ページ訪問時にアナリティクスイベントを送信するとします。

useEffect(() => {
	logVisit(url); // POSTリクエストの送信
}, [url]);

開発環境では、logVisitが呼び出されるのはURLごとに2度ずつです。これを修正したくなるかもしれません。このコードはそのままにしておきましょう。前掲のコード例と同じく、ユーザーから見るかぎり、実行が1回でも2回でも振る舞いは変わりません。実践的な観点からは、logVisitは開発時には何もしないでしょう。開発マシンからのログが本番の計測結果に影響することは望ましくないからです。それに、コンポーネントはファイルを保存するたびに再マウントされます。開発時にはいずれにせよ、余計な再訪問が記録されるのです。

本番環境では訪問ログが重複することはありません

送信しているアナリティクスイベントをデバッグするには、つぎのようなやり方があります。

  • アプリケーションをステージング環境にデプロイする。
    • 本番モードでの実行。
  • 一時的にStrictModeを外して、開発環境専用の再マウントチェックを止める。
  • アナリティクスログを、エフェクトでなく、ルート変更のイベントハンドラから送る。
  • 交差オブザーバーを用いてより正確に分析する。
    • どのコンポーネントがビューポートにあり、どれだけの時間表示されているか追跡できる。

エフェクトではない場合: アプリケーションの初期化

アプリケーションが起動したとき、1度だけ実行されるべきロジックもあるでしょう。そういうコードは、コンポーネントの外に置いてください。

// ブラウザで実行されているかを確かめる
if (typeof window !== 'undefined') {
	checkAuthToken();
	loadDataFromLocalStorage();
}
export default function App() {
	// ...
}

エフェクトではない場合: 商品の購入

クリーンアップ関数を書いても、2度実行されるエフェクトのユーザーから見た影響が防げないこともあります。たとえば、エフェクトがPOSTリクエストを送る、商品の購入のような場合です。

useEffect(() => {
	// 🔴 NG: エフェクトが開発時には2度実行され、コードに問題が生じる
	fetch('/api/buy', { method: 'POST' });
}, []);

同じ商品を2度買いたくありません。ですから、このロジックはエフェクトに入れてはいけないのです。たとえば、ユーザーが別のページに移ってから戻るボタンを押したら、エフェクトは再実行されてしまいます。ユーザーが製品を買うのは、ページの訪問時ではありません。購入ボタンを押したときです。

購入はレンダリングによって引き起こされるのではありません。特定のユーザ操作にもとづくことです。つまり、ユーザがボタンを押したときにのみ実行しなければなりません。/api/buyリクエストは、エフェクトから除き、購入ボタンのイベントハンドラに移してください

const handleClick = () => {
	// ✅ OK: 購入は特定のユーザー操作にもとづくのでイベント
	fetch('/api/buy', { method: 'POST' });
}

つまり、再マウントでロジックが壊れたとすれば、通常バグの存在が明らかになったということです。ユーザーの視点からはつぎのふたつに違いがあってはいけません。

  • ページを訪れること。
  • 訪れたページでリンクをクリックし、移った別のページから戻るボタンを押すこと。

Reactは、コンポーネントがこの原則にしたがっていることを、開発時に1度再マウントして確かめるのです。

エフェクトの実際の動作を感覚として確かめる

エフェクトが実際どのように動作するのか、感覚として確かめましょう。

つぎのコード例では、[Mount the component]ボタンをクリックすると、テキスト入力フィールドが表れます。エフェクトが設定するのは、コンソールに入力フィールドのテキスト(text)を3秒後に出力するsetTimeoutです。クリーンアップ関数は、時間待ち中のタイムアウトを取り消します(clearTimeout)。

src/
export const Playground: FC = () => {
	const [text, setText] = useState('a');
	useEffect(() => {
		const onTimeout = () => {
			console.log('' + text);
		};
		console.log('🔵 Schedule "' + text + '" log');
		const timeoutId = setTimeout(onTimeout, 3000);
		return () => {
			console.log('🟡 Cancel "' + text + '" log');
			clearTimeout(timeoutId);
		};
	}, [text]);

};

[1] はじめに表示されるのは、つぎの3つの出力です。"Schedule"と"Cancel"がひと組み余分に出てくるのは、Reactが開発時にコンポーネントを1度再マウントしていることを示します。クリーンアップは正しく実装されていると確かめられるでしょう。

🔵 Schedule "a" log
🟡 Cancel "a" log
🔵 Schedule "a" log

そして、3秒後に示されるのがタイムアウト終了のつぎの出力です。

⏰ a

[2] つぎに、入力フィールドにテキスト「bc」を素早く加えてください。いったん"Schedule"された「ab」はただちに"Cancel"され、「abc」の"Schedule"が改めて設定されるでしょう。Reactがつぎのレンダーのエフェクトを実行する前にクリーンアップするのは、つねにその前に行ったレンダーのエフェクトです。したがって、前掲コード例でテキストフィールドに素早く入力したとしても、タイムアウトは同時に最大でもひとつしか設定されません。

🔵 Schedule "ab" log
🟡 Cancel "ab" log
🔵 Schedule "abc" log
⏰ abc

[3] 入力フィールドに何かテキストをタイプしたら、すぐに[Unmount the component]ボタンを押してみてください。コンポーネントのアンマウントにより、最後のレンダーのエフェクトはクリーンアップされます。タイムアウトの終了前であれば取り消され、コンソールには出力されません。

src/App.tsx
export default function App() {
	const [show, setShow] = useState(false);
	return (
		<>
			<button onClick={() => setShow(!show)}>
				{show ? 'Unmount' : 'Mount'} the component
			</button>
			{show && <hr />}
			{show && <Playground />}
		</>
	);
}

[4] 最後はクリーンアップ関数を外した場合の確認です。[2]と同じようにテキストフィールドに「bc」を素早く入力してください。

src/
export const Playground: FC = () => {

	useEffect(() => {

		/* return () => {
			console.log('🟡 Cancel "' + text + '" log');
			clearTimeout(timeoutId);
		}; */
	}, [text]);

};

コンソールには、どのように表示されるでしょうか。もちろん、クリーンアップはされません。出力はつぎのとおりです。

🔵 Schedule "ab" log
🔵 Schedule "abc" log
⏰ ab
⏰ abc

タイムアウトが終了したとき、ふたつともに「⏰ abc」が出力されると予想したかもしれません。けれど、各エフェクトは、対応するレンダーのtextの値をそれぞれ「キャプチャ」するのです。textの状態が変わったとしても、エフェクトはレンダーされたときのtextの値をつねに参照し続けます。いいかえれば、エフェクトはレンダーごとに分離されているのです。この原理について詳しく知りたい場合は「クロージャ」が参考になるでしょう。

レンダーごとにエフェクトがある

useEffectはレンダー出力に「付随」する振る舞いのひとつと捉えられます。つぎのChatRoomコンポーネントのエフェクトで考えてみましょう。

ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId }) => {
	useEffect(() => {
		const connection = createConnection(roomId);
		connection.connect();
		return () => connection.disconnect();
	}, [roomId]);
	return <h1>Welcome to {roomId}!</h1>;
};

roomIdの初期値は'general'とします。

App.tsx
export default function App() {
	const [roomId, setRoomId] = useState('general');

	return (
		<>
			<ChatRoom roomId={roomId} />
		</>
	);
}

初期レンダー

ユーザーはroomId="general"<ChatRoom>を訪れます。エフェクトもまた、レンダリング出力の一部です。コードで用いられている状態変数の値をあえて書き出してみましょう(動くコードではありません)。React はこのエフェクトを実行して、チャットルーム'general'に接続します。

	// 初期レンダーのエフェクト(roomId = 'general')
	() => {
		const connection = createConnection('general');
		connection.connect();
		return () => connection.disconnect();
	},
	// 初期レンダーの依存(roomId = 'general')
	['general']

同じ依存値での再レンダー

同じroomId="general"のまま再レンダーするとしましょう。JSXの出力は前回と同じです。Reactはレンダリング出力が変わっていないことを確かめます。したがって、DOMは更新しません。

	// 再レンダーのJSXは初期レンダーと同じ(roomId="general")
	return <h1>Welcome to general!</h1>;

再レンダーのエフェクトは、初期レンダーと同じです。Reactは前回と今回のレンダーの依存配列['general']を比べます。依存配列に変わりはありません。そこで、Reactは再レンダーにおけるエフェクトの実行は省くのです。つまり、エフェクトは呼び出されません。

異なる依存値での再レンダー

ユーザーがroomId="travel"<ChatRoom>を切り替えたとしましょう。コンポーネントは異なるJSXを返します。ReactはDOMを更新しなければなりません。

	// 再レンダーのJSXが変わる(roomId="travel")
	return <h1>Welcome to travel!</h1>;

エフェクトもroomIdの状態変数値が改められました。Reactは今回のレンダーの依存配列['travel']を前回の['general']と比べます。Object.is('travel', 'general')の結果はfalseです。依存が異なりますので、エフェクトは省けません。

	// 異なる依存値で再レンダーしたエフェクト(roomId = 'travel')
	() => {
		const connection = createConnection('travel');
		connection.connect();
		return () => connection.disconnect();
	},
	// 再レンダーで変わった依存(roomId = 'travel')
	['travel']

Reactが今回のレンダーのエフェクトを適用する前にしなければならないのは、最後に実行したエフェクトのクリーンアップです。前回の再レンダーのエフェクトは省略されました。Reactがクリーンアップすべきは、初期レンダーのエフェクトです。クリーンアップのコードは、createConnection('general')でつくられた接続(connection)に対してdisconnect()を呼び出しています。これで、アプリケーションはチャットルーム'general'から切断されるのです。

	// 初期レンダーのエフェクト(roomId = 'general')
	() => {
		const connection = createConnection('general');
		connection.connect();
		return () => connection.disconnect();
	},
	['general']

そのあと、Reactは今回のレンダーのエフェクトを実行します。新たに接続されるのはチャットルーム'travel'です。

アンマウント

そして、ユーザーがページを離れると、<ChatRoom>コンポーネントはアンマウントされます。Reactは、最後のエフェクトのクリーンアップ関数を実行しなければなりません。異なる依存値による再レンダーで実行したのが、最後のエフェクトでした。クリーンアップが破棄するのは、createConnection('travel')の接続です。アプリケーションはチャットルームtravelから切断します。

開発環境専用の動作

StrictModeが有効な場合の動作です。Reactはすべてのコンポーネントを1度マウントしてから再マウントします(状態とDOMは保持されます)。これで、クリーンアップの必要なエフェクトがわかり、競合状態のようなバグも見つけやすいでしょう。さらに、開発環境のReactでは、エフェクトはファイルを保存するたびに再マウントされます。これらは開発時のみの挙動です。

まとめ

この記事では、つぎのような項目についてご説明しました。

  • エフェクトは、レンダリングそのものから引き起こされます。イベントのように特定のユーザー操作にはもとづきません。
  • エフェクトにより、コンポーネントを外部システム(サードパーティAPI、ネットワークなど)と同期できます。
  • デフォルトでは、エフェクトの実行は(初期も含む)毎レンダーのあとです。
  • エフェクトのすべての依存が前回のレンダー時と同じ値であったら、Reactは実行を省きます。
  • 依存値は勝手に「選択」できません。エフェクト内のコードによって決まります。
  • 空の依存配列([])でエフェクトが実行されるのは、コンポーネントの「マウント時」です。つまり、画面に追加されたときを意味します。
  • StrictModeでは、Reactが各コンポーネントを初期マウントするのはそれぞれ2回ずつで(開発環境のみ)、エフェクトのストレステストのためです。
  • 再マウントでエフェクトが破綻するときは、クリーンアップ関数を実装してください。
  • Reactがクリーンアップ関数を呼び出すのは、つぎのエフェクトが実行される前とアンマウント時です。
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1