サイトトップ

Director Flash 書籍 業務内容 プロフィール

HTML5テクニカルノート

React: エフェクトによる同期 ー useEffectのあれこれ


本稿執筆現在、「React Docs」(BETA)の公開が進んでいるようです。その中の記事のひとつ「Synchronizing with Effects」が、初心者にとってはもちろん、中級者以上にとってもわかりにくい副作用とuseEffectフックのあれこれについて詳しく解説しています。その内容をお伝えする目的で書いたのがこの記事です。基本的に情報は網羅しているものの、邦訳ではありません。足りない部分は補ったり、説明の仕方を改めたり、不要と思われる記述は削除しました。コード例の一部は、CodeSandboxに公開しましたので、興味があれば実際にお試しください。

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

エフェクトを用いると、コードがレンダリング後に実行されます。それにより、コンポーネントをReact以外のシステムと同期できるのです。

01 エフェクトとは何か、イベントとどう違うのか?

エフェクトに触れる前に、Reactコンポーネントの中のふたつの種類のロジックについて知っておかなければなりません。

これだけでは、必ずしも十分ではありません。ChatRoomコンポーネントを考えてみましょう。画面に表示されている間は、つねにチャットサーバーに接続する必要があります。サーバーへの接続は純粋な計算ではなく、副作用です。したがって、レンダリング中は発生しません。ところが、ChatRoomを表示するというイベント(たとえばクリック)がとくにないのです。

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

02 エフェクトは要らないこともある

コンポーネントにエフェクトをあわてて加えないでください。エフェクトはReactコードから「一歩引いて」、外部システムと同期するために用いられるのが通常です。これには、ブラウザAPI、サードパーティウィジェット、ネットワークなどが含まれます。ある状態を他の状態にもとづいて調整するだけなら、エフェクトは要らないかもしれません

03 エフェクトの書き方

エフェクトを書くには、3つの段階を踏みます。

03-01 エフェクトを宣言する

コンポーネントでエフェクトを宣言するには、ReactからuseEffectフックをインポートします。


import { useEffect } from 'react';

そして、コンポーネントのトップレベルuseEffectを呼び出し、副作用は引数の関数本体に記述します。


function MyComponent() {
	useEffect(() => {
		// デフォルトではコードがレンダリングのたびに実行される
	});
	return <div />
}

コンポーネントがレンダリングされるたびに、Reactが画面を更新してからuseEffectに渡した(副作用)関数は呼び出されます。つまり、useEffectは、レンダリングが画面に反映されるまで、コードの一部を「遅延」して実行するのです。

エフェクトを用いて、外部システムとどのように同期するのかみてみましょう。Reactコンポーネント<VideoPlayer>があるとします。渡したisPlayingは、ビデオが再生中か一時停止中かを制御するプロパティです。


<VideoPlayer src={src} isPlaying={isPlaying} />;  // srcはビデオのパス

VideoPlayerコンポーネントは、JSXで<video>要素を返してレンダリングします。


function VideoPlayer({ src, isPlaying }) {
	// TODO: isPlayingについての処理
	return <video src={src} />;
}

ただし、isPlayingは、<video>要素に備わるプロパティではありません。再生と一時停止が制御できるようにするには、要素のメソッドplay()pause()をJavaScriptコードで呼び出さなければならないのです。

<video>DOMノードを参照するには、要素にRefを与えます。

そのうえで、コンポーネントからplay()pause()を呼び出せばよいでしょうか。それだけでは、メソッドの呼び出しでエラーが生じてしまいます。

TypeError Cannot read properties of null (reading 'pause')
VideoPlayer.js

import { useRef } from 'react';

export const VideoPlayer = ({ src, isPlaying }) => {
	const ref = useRef(null);
	if (isPlaying) {
		ref.current.play();
	} else {
		ref.current.pause();
	}
	return <video ref={ref} src={src} loop playsInline />;
};

なお、VideoPlayerコンポーネントを読み込むルートモジュールApp.jsの記述は、つぎのコード001のとおりです。

コード001■ルートモジュール

App.js

import { useState } from 'react';
import { VideoPlayer } from './VideoPlayer';
import './styles.css';

export default function App() {
	const [isPlaying, setIsPlaying] = useState(false);
	return (
		<div className="App">
			<button onClick={() => setIsPlaying(!isPlaying)}>
				{isPlaying ? 'Pause' : 'Play'}
			</button>
			<VideoPlayer
				isPlaying={isPlaying}
				src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
			/>
		</div>
	);
}

原因は、DOMノードをレンダリング中に操作しようとしていることです。Reactでは、レンダリングはJSXの純粋な計算でなければなりません。DOMの変更などの副作用を含むことは避けるべきです。

しかも、VideoPlayerがはじめて呼び出されたとき、DOMはまだ存在しません。ReactはつくるべきDOMを、JSXが返されるまで知らないのです。

解決するには、副作用をuseEffectで包んで、レンダリングの計算から外します

VideoPlayer.js

// import { useEffect, useRef } from 'react';
import { useRef } from 'react';

export const VideoPlayer = ({ src, isPlaying }) => {

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

};

DOMの更新をエフェクトで包むことにより、Reactは画面を先に描画するのです。副作用はそのあと実行されます。

VideoPlayerコンポーネントがレンダリングされるとき(初回および再描画時)に起こるのはつぎのようなことです。

Play/Pauseボタンを繰り返し押してみれば、ビデオプレーヤーがisPlayingプロパティの値に同期していると確かめられるでしょう(サンプル001)。VideoPlayerモジュールの記述全体は、つぎのコード002のとおりです。

コード002■VideoPlayerモジュール

VideoPlayer.js

import { useEffect, useRef } from 'react';

export const 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 />;
};

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

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

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

ノート01■エフェクトの無限ループ

デフォルトでは、エフェクトはレンダリングのたびに、そのあと実行されます。つぎのようなコードが、無限ループを起こす理由です。


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

エフェクトの実行はレンダリングの結果です。状態を設定すると、レンダリングが行われます。レンダリングされれば、そのあと走るのがエフェクトです。すると、新たな状態にもとづいて、再びレンダリングされてしまいます。この繰り返しの結果起こるのが無限ループです。

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

03-02 エフェクトの依存を定める

エフェクトは、レンダリングのたび、そのあとに実行されます。けれど、それが望ましくない場合もあるでしょう。

問題を実際に示すため、前の作例のVideoPlayerにconsole.log()の呼び出しと、親のAppにはテキスト入力フィールドを加えました。テキストを入力すると親コンポーネントの状態(text)が変わります。すると、エフェクトが再実行され、コンソール出力されることにご注目ください(サンプル002)。

VideoPlayer.js

export const VideoPlayer = ({ src, isPlaying }) => {

	useEffect(() => {
		console.log(`Calling video.${isPlaying ? 'play' : 'pause'}()`);

	});

};

App.js

export default function App() {

	const [text, setText] = useState('');
	return (
		<div className="App">
			<input
				type="text"
				value={text}
				onChange={(event) => setText(event.target.value)}
			/>

		</div>
	);
}

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

Reactに必要のないエフェクトを再実行しないよう指示できます。そのときuseEffectの呼び出しに加えるのが、依存を定めた第2引数の配列です。まずは、前掲サンプル002のuseEffectに空の配列[]を与えてみましょう。

VideoPlayer.js

useEffect(() => {
	// ...
}, []);

すると、Linterからつぎのような警告が示されるはずです。しかも、ボタンを押してもビデオが再生されません。それは、空の配列[]は依存がないことを示し、副作用は1度(マウント時とアンマウント時)だけしか実行されないからです(「ヒント:副作用のスキップによるパフォーマンス改善」参照)。

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

警告が告げるとおり、依存配列にプロパティisPlayingを加えなければなりません。そうすれば、依存するisPlayingの値が変わったときのみ、副作用は再実行されるのです。

VideoPlayer.js

useEffect(() => {
	if (isPlaying) { // プロパティ値を用いて判別
		// ...
	} else {
		// ...
	}
}, [isPlaying]); // 依存するプロパティを配列に加える

依存配列の要素をReactは監視します。前のレンダリングから値が変わっていなければ、副作用の再実行は省かれるのです。テキストフィールドに入力して親コンポーネントの状態が変わっても、isPlayingの値はそのままです。したがって、副作用は働きません。ボタンが押されて依存値が変わったときだけ、副作用は再実行されるのです。

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

依存配列には、複数の依存が含められます。Reactが副作用を再実行するのは、そのうちのいずれかの値が変わったときです。すべての依存値が前のレンダリング時と同じなら、副作用は働きません。Reactが依存値の比較に用いるのは、Object.is()です。詳しくは「useEffect」をご参照ください。

Linterが依存に含めるよう警告した値を、あえて副作用の再実行から外したい場合があるかもしれません。そのときは、依存配列から個別に除いてください。

ノート02■useEffectの第2引数の依存配列

useEffectの第2引数の依存配列を省くのと、空の配列[]を渡すのでは意味が大きく異なります。


useEffect(() => {
	// 毎レンダリング後に実行される。
});

useEffect(() => {
	// マウントおよびアンマウント時(コンポーネントの表示・非表示のとき)に1度だけ実行される。
}, []);

useEffect(() => {
	// マウントおよびアンマウント時に加えて前回レンダリング時からaまたはbの値が変わったときに再実行される。
}, [a, b]);

ノート03■なぜrefは依存配列に含めなくてよいのか

VideoPlayerコンポーネントのuseEffectに渡した関数は、isPlayingプロパティだけでなく、変数refも参照しています。変数は依存配列に含めなくてよいのでしょうか。

VideoPlayer.js

export const VideoPlayer = ({ src, isPlaying }) => {
	const ref = useRef(null);
	useEffect(() => {
		if (isPlaying) {
			ref.current.play();
		} else {
			ref.current.pause();
		}
	});

};

これはrefオブジェクトが変化しない同質性をもつからです。Reactは、ひとつのuseRef呼び出しからは、レンダリングのたびにつねに同じオブジェクトが得られることを保証します。変わらないので、副作用の再実行は起こしません。つまり、依存に含めても含めなくても同じなのです。含めてもとくに問題はありません。

useStateが返す配列の第2要素(インデックス1)の設定関数も、変化しない同質性を備えます。ですから、依存からは省かれることが少なくありません。Linterが警告を発しなければ、除いて構わないでしょう。

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

03-03 必要に応じてクリーンアップ関数を加える

ChatRoomコンポーネントを書いて、それが表示されたときチャットサーバーに接続したいとします。与えられたAPIのcreateConnection()が返すのは、メソッドconnect()disconnect()を備えたオブジェクトです。ユーザーがコンポーネントを表示している間接続しつづけるにはどうすればよいでしょう。

まずは、エフェクトのロジックから書き始めます。


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

これでは、アプリケーションの動きが遅くなってしまいます。レンダリングのたびに、チャットに接続するからです。そこで、依存配列を加えます。


useEffect(() => {

}, []);

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

そこで、つぎのコードを試してみます。

chat.js

export const createConnection = () => {
	// 実際にはサーバーへの接続と切断を実装する。
	const connect = () => {
		console.log('Connecting...');
	};
	const disconnect = () => {
		console.log('Disconnected.');
	};
	return { connect, disconnect };
};

ChatRoom.js

import { useEffect } from 'react';
import { createConnection } from './chat.js';
import './styles.css';

export default function ChatRoom() {
	useEffect(() => {
		const connection = createConnection();
		connection.connect();
	}, []);
	return (
		<div className="App">
			<h1>Welcome to the chat!</h1>
		</div>
	);
}

このエフェクトはマウントでのみ実行されるため、コンソールには「Connecting...」と1回だけ出力されると予想したかもしれません。ところが、コンソールを確かめると、同じ出力が2回行われるのです。これはなぜでしょうか。

(2) Connecting...

ChatRoomコンポーネントが大きなアプリケーションの一部で、多くの異なる画面が備わっていたとします。ユーザーがはじめに開くのはChatRoomのページです。コンポーネントはマウントされて、副作用のconnection.connect()が呼び出されます。そのあと、別の画面たとえば設定ページに移ったとしましょう。ChatRoomコンポーネントがアンマウントされます。 そのうえで、ユーザーが[戻る]をクリックしたら、ChatRoomはふたたびマウントされるのです。すると、2度目の接続が設定されます。けれど、はじめの接続が破棄されていません。ユーザーがアプリケーション内のページを移動することにより、接続がたまってしまうのです。

こうしたバグは、手作業による広範なテストを行わないかぎり、見逃されやすくなります。問題がすばやく見つけられるように、Reactは開発時にはすべてのコンポーネントをはじめのマウントの直後に再マウントするのです。「Connecting...」のログを2回見ることで、本当の問題に気づきやすくなりますコンポーネントがアンマウントされたとき、コードは接続を閉じていないということです。

この問題を解決するには、useEffectからクリーンアップ関数を返してください。


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

Reactがクリーンアップ関数を呼び出すのは、つねにエフェクトが再実行される前です。そして、最後にコンポーネントがアンマウント(削除)されるときに、クリーンアップを行います。書き替えたChatRoomコンポーネントの記述全体は、つぎのコード003のとおりです。実際の動きについては、サンプル004のCodeSandbox作例をお確かめください。

コード003■ChatRoomモジュール

ChatRoom.js

import { useEffect } from 'react';
import { createConnection } from './chat.js';
import './styles.css';

export default function ChatRoom() {
	useEffect(() => {
		const connection = createConnection();
		connection.connect();
		return () => {
			connection.disconnect();
		};
	}, []);
	return (
		<div className="App">
			<h1>Welcome to the chat!</h1>
		</div>
	);
}

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

開発時のコンソールには、つぎの3つの出力が示されるでしょう。

これは開発時における正しい動作です。コンポーネントの再マウントにより、Reactはナビゲーションで遷移して戻っても、コードが破綻しないことを確かめます。切断したのち再接続することは、まさに適切な動作です。クリーンアップが正しく実装されていれば、ユーザーから見て1度だけエフェクトを実行することと、そのあとクリーンアップしてから再実行することに差はありません。接続・再接続の呼び出しを1回余分に行うのは、Reactがコードにバグがないか検証するためです。これは正常なことで、除こうとするべきではありません。

開発ではなく本番環境では「Connecting...」の出力は1度だけです。コンポーネントの再マウントは、開発時のみクリーンアップが必要なエフェクトを見つけられるように行われます。StrictModeをオフにすれば、開発時のこの機能は働きません。けれど、オンにしておくことをお勧めします。前述のような多くのバグを見つけることができるからです。

04 開発時に2度実行されるエフェクトの扱い方

Reactは、開発時はあえてコンポーネントを再マウントします。前述のようなバグを見つけるのに役立てるためです。つまり、正しい問いは「エフェクトの実行を1度だけにする方法」ではありません「エフェクトが再マウントされたあともどうやって正しく動作させるか」です

通常は、クリーンアップ関数を実装して対応します。クリーンアップが行うのは、エフェクトの実行していたことをすべて停止あるいはもとに戻すことです。大抵のユーザーは、(本番環境の)1度だけのエフェクトと、(開発時の)副作用→クリーンアップ→副作用の実行の違いには気づきません。

エフェクトの多くに共通するパターンをご紹介しましょう。

04-01 React以外のウィジェットの制御

Reactで書かれていないUIウィジェットを組み込む場合です。たとえば、地図のコンポーネントをページに加えるとしましょう。地図はsetZoomLevel()メソッドを備えていて、ズーム率はReactコードの状態変数zoomLevelと同期させます。つぎのコードがエフェクトの記述です。


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

この場合には、クリーンアップは要りません。開発時に、Reactはエフェクトをはじめに2度実行します。けれど、setZoomLevelを同じ値で2回呼び出したからといって変化はないので、問題にはならないのです。動きが少しもたつくことはありえます。それでも、再マウントが起こるのは開発時のみです。本番環境では、結局問題ありません。

APIによっては、2回続けて呼び出せないかもしれません。たとえば、組み込みの<dialog>要素に対するshowModal()メソッドです。続けざまに2度呼び出せばエラーになるでしょう。クリーンアップ関数を加えて、ダイアログは閉じなければなりません。


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

開発環境では、エフェクトはshowModal()に続けてすぐにclose()を呼び出し、そしてshowModal()の再実行となります。動きとしては、ユーザーが見るshowModal()を1度だけ呼び出すのと変わりません。本番環境になれば、エフェクトのはじめの実行は1回です。

04-02 イベントのサブスクライブ

エフェクトが何かをサブスクライブしている場合、クリーンアップ関数が行うべきはサブスクライブの解除です。


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

このコード例は開発環境では、エフェクトはaddEventListener()に続けてハンドラはただちにremoveEventListener()で除き、同じハンドラをaddEventListener()でふたたび加えています。したがって、有効なサブスクリプションは1度にひとつだけです。動きとしては、ユーザーが見るaddEventListener()を1度だけ呼び出すのと変わりません。本番環境になれば、エフェクトのはじめの実行は1回です。

04-03 アニメーションの実行

エフェクトで何かをアニメーションさせたら、クリーンアップ関数が行うべきなのは初期値にリセットすることです。


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

このコード例のエフェクトは、はじめアニメーションに使う不透明度のプロパティ値を1に設定しています。開発環境では、ただちにクリーンアップ関数が0にリセットし、副作用の再実行で与えられる値が改めて1です。動きとしては、ユーザーが見るはじめから1であるのと変わりません。本番環境になれば、エフェクトのはじめの実行は1度きりです。トゥイーンをサポートするサードパーティのアニメーションライブラリで再生する場合は、クリーンアップ関数はタイムラインを初期状態にリセットすることになるでしょう。

04-04 データの読み込み

エフェクトで実行するのがデータの読み込みのときは、クリーンアップ関数が行うのは読み込みの中断か、結果の無視です。


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

すでに発信したネットワークリクエストは、なかったことにはできません。けれど、クリーンアップは、もう関係なくなったデータの受信が、そのあとアプリケーションに影響を与えないようにすべきです。このコード例では、userIdが変わったらクリーンアップが前のレスポンスは無視して、副作用により新たなuserIdでデータを取得します。

ただし、開発環境ではふたつのデータ取得が実行されるはずです。けれど、このコード例では問題ありません。はじめのエフェクトには、ただちにクリーンアップがかかり、関数の外の変数ignoretrueが与えられます。レスポンス(json)は受け取っても、値の条件判定によりその先の処理(setTodos())には渡されないからです。

もちろん、本番環境ではリクエストははじめにひとつしか送られません。開発時にふたつめのリクエストが気になるようでしたら、重複した発信は除くのが最善です。レスポンスはコンポーネント間でキャッシュします。


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

これにより、開発体験が向上するだけではありません。アプリケーションの動きも速く感じられます。たとえば、ユーザーが[戻る]ボタンを押しても、データがキャッシュされているので、読み込みを待たずに済むのです。このようなキャッシュは自作しても構いません。あるいは、既存のさまざまな選択肢を用いて、エフェクトで手動フェッチすることもできます。

ノート04■エフェクトでデータをどのように取得するのがよいか

エフェクトの中にデータ取得のコードを書くのは、とくに完全にクライアント側のアプリケーションなら、一般的なやり方です。ただ、手間がかるうえに、問題も少なくありません。

上記に掲げた問題は、Reactにかぎったことではありません。マウント時にデータを取得するなら、どのようなライブラリにも当てはまります。ルーティングと同じく、うまくデータフェッチするのはたやすくありません。お勧めするのは、つぎのふたつのやり方のどちらかです。

ふたつのいずれも適していない場合には、エフェクトで直接データを取得しても差し支えありません。

04-05 分析の送信

ページを訪問したときに分析のイベントを送るつぎのコードについて考えてみましょう。


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

開発段階では、logVisitははじめ同じURLに対して2度呼ばれます。これは避けたいと感じるかもしれません。けれど、コードはそのままにしておくのがよいでしょう。前項のコード例と同じように、ユーザーから見た動きは、実行が1回だろうと2回だろうと違わないからです。実践的な場面では、logVisitは開発時には何もしないでしょう。開発用マシンからのログが、本番用のメトリクスを歪めてしまうことは避けたいからです。開発時、コンポーネントはファイルを保存するたびに再マウントされます。余計な訪問が送信されたからといって気にはならないでしょう。

本番環境では訪問ログが再送信されることはないのです。

送信される分析イベントのデバッグは、アプリケーションをステージング環境(本番モードで実行)にデプロイすればできます。あるいは、StrictModeは一時的にオプトアウトして、開発専用の再マウントチェクをしてもよいでしょう。エフェクトに替えて、ルート変更イベントハンドラから分析を送信する手もあります。さらに正確な分析をするために使えるのが、交差オブザーバー(intersection observers)です。どのコンポーネントがビューポートにあり、どれくらいの間表示されているか調べるのに役立ちます。

04-06 エフェクトなし: アプリケーションの初期化

ロジックによっては、アプリケーションの起動時に1度だけ実行すれば済みます。その場合には、コンポーネントの外に出してしまえばよいでしょう。


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

コンポーネントの外に置いたロジックは、ブラウザがページを読み込んだあと1度だけ実行されることが保証されます。

04-07 エフェクトなし: 製品の購入

クリーンアップ関数を書いても、エフェクトが2回実行されてしまうと、ユーザーから見た結果は1回同じにできないこともあります。たとえば、エフェクトが製品購入のようなPOSTリクエストを送る場合です。


useEffect(() => {
	// 🔴 NG: 開発時、副作用が2度実行されると、コードに問題が発生する。
	fetch('/api/buy', { method: 'POST' });
}, []);

製品の重複購入は避けなければなりません。つまり、ロジックをエフェクトに置いてはいけないのです。ユーザーが他のページに遷移して、[戻る]をクリックしたらどうなるでしょう。エフェクトは再実行されます。製品を購入するのは、ユーザーのページ訪問時ではありません。ユーザーが購入ボタンをクリックしたときです。

購入はレンダリングで引き起こされてはなりません。特定のインタラクションによって実行されるべきです。そうすれば、購入するのは1度、インタラクション(クリック)が起こったときとなります。/api/buyのリクエストをエフェクトから除き、購入ボタンのイベントハンドラに移すのが適切です。


function handleClick() {
	// ✅ 購入はイベントで特定のインタラクションから実行する。
	fetch('/api/buy', { method: 'POST' });
}

マウントでアプリケーションのロジックが壊れるというのは、通常バグの存在を示すということです。ユーザーから見てページの訪問は、ページにアクセスして別ページへのリンクをクリックしたうえで[戻る]のと、結果が同じでなければなりません。Reactは、コンポーネントがこの原則から外れていないことを、開発時の再マウントにより確かめているのです。

05 エフェクトの結果を試す

以下のコード004のコンポーネントPlaygroundで、エフェクトの結果を確かめてみましょう。そのためのサンプル005をCodeSandboxに掲げました。

この作例は、setTimeout()で入力フィールドのテキストを、副作用の実行から3秒後にコンソール出力します。クリーンアップ関数が行うのは、clearTimeout()の呼び出しによる保留中のタイムアウトの解除です。

コード004■Playgroundモジュール

Playground.js

import { useState, useEffect } from 'react';

export const Playground = () => {
	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]);
	return (
		<>
			<label>
				What to log:{' '}
				<input
					value={text}
					onChange={({ target: { value } }) => setText(value)}
				/>
			</label>
			<h1>{text}</h1>
		</>
	);
};

[Mount the component]ボタンのクリックで、Playgroundコンポーネントはマウントされ、エフェクトが開始します。

サンプル005■React: Synchronizing with Effects 05

コンソールにはじめに出力されるのは、つぎの3つのログです。

そして、3秒後につぎの出力が示されるでしょう。

出力に加わった副作用のクリーンアップと再実行のログは、開発時にReactがコンポーネントを1度再マウントしていることによります。こうして、クリーンアップが正しく実装されていることを確かめられるのです。

つぎに、テキスト入力フィールドに「bc」を加え、「abc」としてみましょう。ひと文字入力するごとに、前の副作用はキャンセルされ、新たなスケジュールが加わります。

新たなレンダリングのエフェクトが加えられる前に、Reactはつねに前の副作用をクリーンアップするのです。どれだけ素早く入力しても、1度に最大でひとつしかタイムアウトはスケジュールされません。

Playgroundコンポーネントがマウントされていると、ボタンは[Unmount the component]に変わっているはずです。テクストフィールドに続けて入力し、すぐにこのボタンを押してみてください。マウントが外れることにより、最後のレンダリングのエフェクトがクリーンアップされるでしょう。タイムアウトは解除されて、コンソールには何も出力されません。

最後に、Playgroundコンポーネントに手を加えて、クリーンアップ関数はコメントアウトします。これで、タイムアウトは解除されません。

Playground.js

export const Playground = () => {

	useEffect(() => {

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

};

改めてPlaygroundコンポーネントをマウントすると、はじめのエフェクトがクリーンアップされないので、タイムアウトのログはふたつ出力されます。

では、テキストフィールドに「bc」と入力を加えて、「abc」にしてみます。クリーンアップされませんから、ふたつのタイムアウトがスケジュールされました。

ここで問題です。3秒後にはどのようなログが出力されるでしょうか。最終的に入力された「abc」が2回表示されると予測したかもしれません。実際の出力はつぎのとおりです。

各エフェクトは、それぞれのレンダリングの状態変数(text)の値を保持するからです。最終的な状態編数値を参照するのではありません。つまり、エフェクトはレンダリングごとに分離されているのです。この仕組みについてさらに詳しくは「クロージャ」をご参照ください。

ノート05■レンダリングごとにエフェクトがある

useEffectは、レンダリング出力にふるまいを「つけ加える」と捉えられます。たとえば、つぎのChatRoomコンポーネントのエフェクトです。

ChatRoom.js

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

ユーザーがこのアプリケーションを操作すると何が起こるのか、細かく見ていきましょう。

初期レンダリング

親コンポーネントがChatRoomにプロパティroomIdとして、値"general"を与えてページに差し込んでいたとします。すると、コンポーネントが返すJSXは、つぎのの記述と変わりません。「Rendering takes a snapshot in time」にならって、コードに具体的な値をあてはめながら考えましょう。

ChatRoom.js

// 最初のレンダリングのJSX(roomId="general")
return <h1>Welcome to general!</h1>;

エフェクトもレンダリング出力に含まれます。したがって、初期レンダリングのエフェクトは、結果としてつぎの記述と同じです。

ChatRoom.js

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

Reactはこのエフェクトを実行して、チャットルーム「general」に接続します。

同じ依存における再レンダリング

<ChatRoom roomId="general" />が再レンダリングされるとしましょう。JSX出力は変わりません。

ChatRoom.js

// 2度目のレンダリングのJSX(roomId="general")
return <h1>Welcome to general!</h1>;

Reactはレンダリング出力に違いがないと知ります。そのため、DOMは更新しません。

2度目のレンダリングのエフェクトは、つぎのように捉えられるでしょう。

ChatRoom.js

// 2度目のレンダリング時のエフェクト(roomId="general")
() => {
	const connection = createConnection('general');
	connection.connect();
	return () => connection.disconnect();
},
// 2度目のレンダリング時の依存(roomId="general")
['general']

Reactは初期と2度目のレンダリングの依存を比べて、同じ['general']だと確かめます。そして、依存配列の要素がすべて変わらないなら、2度目のレンダリングのエフェクトは無視されるのです。副作用は実行されません。

異なる依存における再レンダリング

つぎに、ユーザーが訪れたのは<ChatRoom roomId="travel" />です。ChatRoomコンポーネントは、異なるJSXを返します。

ChatRoom.js

// 3度目のレンダリングのJSX(roomId="travel")
return <h1>Welcome to travel!</h1>;

すると、ReactはDOMを更新しなければなりません。「Welcome to」に続くroomIdのテキストは「general」から「travel」に改められます。

3度目のレンダリングのエフェクトも、つぎのように変わるでしょう。

ChatRoom.js

// 3度目のレンダリング時のエフェクト(roomId="travel")
() => {
	const connection = createConnection('travel');
	connection.connect();
	return () => connection.disconnect();
},
// 3度目のレンダリング時の依存(roomId="travel")
['travel']

Reactは3度目のレンダリングの依存配列['travel']を2度目の['general']と比べます。依存が同じではありません。Object.is('travel', 'general')falseです。したがって、エフェクトの実行は省けません。

Reactが3度目のレンダリングでエフェクトを加える前に、直前に実行した副作用はクリーンアップしなければなりません。2度目のレンダリングでは、エフェクトは省きました。つまり、Reactがクリーンアップすべきは、最初のレンダリングのエフェクトです。はじめのレンダリングのエフェクトまで遡ると、クリーンアップはdisconnect()を呼び出していることがわかります。そこで、createConnection('general')によりつくられた接続を切るのです。アプリケーションはチャットルーム「general」から抜けます。

そのうえで実行されるのが、3度目のレンダリングのエフェクトです。こうして、チャットルーム「travel」に接続します。

アンマウント

最後の遷移は、ユーザーの退室です。ChatRoomコンポーネントはマウントが外れます。そして、Reactが実行するのは、最後のエフェクトのクリーンアップ関数です。つまり、3度目のレンダリングのエフェクトがクリーンアップされ、createConnection('travel')でつくられた接続は破棄されます。こうして、アプリケーションは「travel」ルームから切断されるのです。

開発時のみの動作

StrictModeが有効のとき、Reactはすべてのコンポーネントについて、マウント後に再マウントします(状態とDOMは保持)。クリーンアップしなければならないエフェクトが見つけやすくなり、競合状態などのバグが早期に発見できるのです。また、開発中にファイルを保存するたびに、Reactはエフェクトを再マウントします。これらの動作は、いずれも開発専用です。

06 まとめ


作成者: 野中文雄
作成日: 2022年07月11日


Copyright © 2001-2020 Fumio Nonaka.  All rights reserved.