2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

React + TypeScript: エフェクトの依存を除く

Last updated at Posted at 2023-07-31

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

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

エフェクトを書くと、リンターはコードに(プロパティや状態などの)リアクティブな値が含まれているかどうか確かめます。それらの値は、すべてエフェクトが読み取る依存配列に加えられていなければなりません。これにより、エフェクトはコンポーネントの最新のプロパティや状態と同期が保てるのです。要らない依存値が含まれていると、エフェクトは必要以上に実行されたり、無限ループを引き起こすかもしれません。本稿が解説するのは、エフェクトの不要な依存値をいかに見つけ、取り除くかです。

依存配列はコードに合致しなければならない

エフェクトを書くときは、何を実行するにしても、はじめに開始と停止を定めなければなりません。

src/ChatRoom.tsx
const serverUrl = 'https://localhost:1234';
export const ChatRoom: FC<Props> = ({ roomId }) => {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		return () => connection.disconnect();
	}, []);

};

ChatRoomコンポーネントのエフェクトは、依存配列を空([])にしました。すると、リンターから示されるのはつぎの警告です。依存値にroomIdが足りません。

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

リンターの指示どおりroomIdを依存配列に加えれば、警告はなくなります(サンプル001)。

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId }) => {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		return () => connection.disconnect();
		// }, []);
	}, [roomId]); // ✅ OK: 必要な依存値が加えられた
	return <h1>Welcome to the {roomId} room!</h1>;
};

サンプル001■React + TypeScript: Removing Effect Dependencies 01

リアクティブな値に「反応」(react)するのがエフェクトです。そして、roomIdの値は(再レンダー時に変わるかもしれないので)リアクティブだといえます。リンターは、その値が依存配列に定められているか確かめなければなりません。そのうえで、roomIdが異なった値を受け取ったら、Reactはエフェクトを再同期するのです。こうして、チャットは選択された部屋との接続が保たれ、ドロップダウンにも確実に「反応」できるようになります。

依存配列から除くためには依存しないことを明らかにする

エフェクトの依存値は勝手に「選ぶ」ものではありません。エフェクトのコードで用いられるすべてのリアクティブな値は、依存配列に宣言しなければならないのです。依存配列に加えるべき値は、エフェクトのコードによって決まります。

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId }) => { // プロパティはリアクティブな値
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId); // エフェクトがリアクティブな値を読み込む
		connection.connect();
		return () => connection.disconnect();
	}, [roomId]); // ✅ OK: リアクティブな値をエフェクトの依存配列に定める

};

リアクティブな値に含まれるのは、プロパティおよびコンポーネント内に直接宣言されたすべての変数と関数です。roomIdはリアクティブな値なので、依存配列から除くことはできません。リンターが許さないのです。

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId }) => { // プロパティはリアクティブな値
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId); // エフェクトがリアクティブな値を読み込む
		connection.connect();
		return () => connection.disconnect();
	}, []);// 🔴 NG: roomIdが依存配列に加わっていない

};

リンターにはしたがわなければなりません。roomIdの値は変わるかもしれないからです。その結果、コードのバグにつながる可能性があります。

依存値を除きたいという場合には、リンターに値が依存配列には要らないと「証明」することです。前掲コード例では、エフェクト内のcreateConnection()の呼び出しには、roomIdだけでなくserverUrlも引数に渡されていました。けれど、リンターはserverUrlは依存値として求めません。それは、serverUrlがコンポーネントの外に宣言されているからです。すると、変数値はリアクティブではありません。再レンダーされても値が変わらないことをリンターに示せるのです。

src/ChatRoom.tsx
const serverUrl = 'https://localhost:1234'; // コンポーネント外に宣言された変数はリアクティブではない
export const ChatRoom: FC<Props> = ({ roomId }) => {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		return () => connection.disconnect();
	}, [roomId]); // ✅ OK: エフェクトの依存配列にはリアクティブな値を含める

};

同じように、roomIdもコンポーネントの外に移せば、リアクティブではなくなります。依存がないので、依存配列は空([])です。ただし、もはやリアクティブではなくなったため、ドロップダウンでエフェクトは再実行されず、部屋は切り替わりません。

src/ChatRoom.tsx
const roomId = 'music';
export const ChatRoom: FC = () => {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		return () => connection.disconnect();
	}, []); // ✅ OK: エフェクトが依存するリアクティブな値はない

};

依存配列を変えるにはコードの変更が必要

依存配列を変える手順に気づいた方もいるでしょう。

  1. まず、エフェクトのコードを書き替えるか、リアクティブな値の宣言を変えてください。
  2. つぎに、リンターの指示にしたがって、依存配列を調整します。加えたコードの変更に合致させるのです。
  3. 結果の依存配列が意図に合わないときは、最初の手順に戻ってください(改めてコードを変更します)。

重要なのは最後の手順です。依存配列を変えるには、まずエフェクトのコードが変更されなければなりません。つまり、依存配列とはエフェクトのコードが用いるすべてのリアクティブな値のリストです。依存配列に加える値は、勝手に選んではいけません。依存配列はコードから導かれる説明の役割を果たします。依存配列を変えたいなら、変更すべきはコードです。

リンターの警告無視は設定しない

リンターの警告は、つぎのように無視の設定を加えることで消せます。

useEffect(() => {

	// 🔴 NG: つぎの設定でリンターからの依存の警告を無視する
	// eslint-ignore-next-line react-hooks/exhaustive-deps
}, []);

依存配列がコードと合致しなければ、バグはきわめて起こりやすくなるでしょう。リンターの警告が止まるというのは、エフェクトの依存値についてReactを「欺く」ことです。それに替わる手法は後述します。

依存値に対するリンターを止めるとなぜ危険なのか

つぎのコードは、依存値に対するリンターを止めたときに起こる問題の比較的単純な例です。

src/App.tsx
export default function Timer() {
	const [count, setCount] = useState(0);
	const [increment, setIncrement] = useState(1);

	const onTick = () => {
		setCount(count + increment);
	};
	useEffect(() => {
		const id = setInterval(onTick, 1000);
		return () => clearInterval(id);
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, []);

}

コンポーネントTimerのエフェクトは、setInterval()にコールバックonTickを定めます。1秒ごとに呼び出されるonTickは、カウンターの状態変数値countを加算する関数です(加算される値incrementは、ボタンで設定されます)。

ところが、このエフェクトは依存配列を空[](依存なし)にして、リンターの警告は止めてしまいました。すると、Reactが使い続けるのはいつまでも初期レンダー時のonTickです。そのレンダー中の状態変数の値は、count0、increment1のまま、onTickが毎秒呼び出されます。結果として、カウンターに示される値は、1から変わりません。このコード例は単純です。けれど、複数コンポーネントに処理がわたると、見つけにくく直しづらいバグになることも少なくありません。

リンターにはしたがうべきです。警告無視の設定を外せば、onTickが依存から抜けていると告げられるでしょう。依存配列に加えれば、カウンターは正しく動きます。ただし、リンターから示されるのは新たな警告です。

The 'onTick' function makes the dependencies of useEffect Hook change on every render. Move it inside the useEffect callback. Alternatively, wrap the definition of 'onTick' in its own useCallback() Hook.

src/App.tsx
export default function Timer() {

	useEffect(() => {
		const id = setInterval(onTick, 1000);
		return () => clearInterval(id);
		// }, []);
	}, [onTick]);

}

コンポーネント内に宣言された関数onTickは、レンダーのたびにつくり直されます。無駄に関数を生成しないためには、リンターの指示どおりコールバックonTickをエフェクトの中に移しましょう。すると、依存値への警告が変わるはずです。

関数onTickは、エフェクトの中に定められました。したがって、エフェクトの読み込むリアクティブな依存値は、関数が参照している状態変数countincrementになったのです(サンプル002)。

src/App.tsx
export default function Timer() {

	/* const onTick = () => {
		setCount(count + increment);
	}; */
	useEffect(() => {
		const onTick = () => {
			setCount(count + increment);
		};
		const id = setInterval(onTick, 1000);
		return () => clearInterval(id);
		// }, [onTick]);
	}, [count, increment]);

}

サンプル002■React + TypeScript: Removing Effect Dependencies 02

依存配列についてのリンターによる警告は、コンパイルエラーとして扱いましょうリンターを止めないことにより、前述のようなバグやエフェクトのコードの問題も見つけて解決できるのです

必要のない依存を除くには

エフェクトの依存を調整して、コードに反映させたら、依存配列を確かめましょう。エフェクトを、依存値のいずれかが変わったら再実行するのは適切でしょうか。必ずしも、そうとはいえないかもしれません。

  • 条件によって、エフェクトの異なる部分のコードを再実行したいことがあります。
  • 依存の最新値を読み込みたいだけであれば、変更に「反応」する必要はありません。
  • 依存が意図せず頻繁に変更されるのは、オブジェクトや関数を値とする場合です。

正しい解決を見出すには、エフェクトについてのいくつかの質問に答えなければなりません。

コードはイベントハンドラに移した方がよいか

まず、そのコードがそもそもエフェクトであるべきかどうかです。

フォームで考えてみましょう。送信時に行う処理はふたつです。

  • POSTリクエストの送信(post)。
  • 通知の表示(showNotification)。

エフェクトを用いて、つぎのようにコードを組み立てるかもしれません。

  • 送信時(handleSubmit)に状態変数submittedの値をtrueに設定。
  • エフェクトがsubmittedの値trueに「反応」。
function Form() {
	const [submitted, setSubmitted] = useState(false);
	const handleSubmit = () => {
		setSubmitted(true);
	}
	useEffect(() => {
		if (submitted) {
			// 🔴 NG: イベントに指定すべきロジックがエフェクトにある
			post('/api/register');
			showNotification('Successfully registered!');
		}
	}, [submitted]);

}

あとから、通知メッセージ(showNotification)に現在のテーマに応じたスタイルを与えることになりました。テーマ(theme)はコンポーネント本体で宣言されています。したがって、リアクティブな値です。エフェクトの依存配列に加えなければなりません。

function Form() {

	const theme = useContext(ThemeContext);

	useEffect(() => {
		if (submitted) {
			// 🔴 NG: イベントに指定すべきロジックがエフェクトにある
			post('/api/register');
			// showNotification('Successfully registered!');
			showNotification('Successfully registered!', theme);
		}
	// }, [submitted]);
	}, [submitted, theme]); // ✅ OK: 依存はすべて宣言

}

すると、バグが引き起こされます。先に、フォームを送信してから、ダークとライトのテーマが切り替わった場合です。themeの値が変わったので、エフェクトは再実行されます。そのため、同じ通知がまた表示されてしまうのです。

そもそもの問題として、フォームの送信はエフェクトに加えるべきではありません。POSTリクエストを送り、フォーム送信に応じた通知が表示されるというのは、特定のユーザー操作によるものです。コードを特定のユーザー操作に応じて実行するのであれば、ロジックは対応するイベントハンドラに直接加えなければなりません。

function Form() {
	// const [submitted, setSubmitted] = useState(false);

	const handleSubmit = () => {
		// setSubmitted(true);
		// ✅ OK: イベントに指定すべきロジックはイベントハンドラから呼び出す
		post('/api/register');
		showNotification('Successfully registered!', theme);
	}
	/* useEffect(() => {
		if (submitted) {
			post('/api/register');
			showNotification('Successfully registered!', theme);
		}
	}, [submitted, theme]); */

}

コードがイベントハンドラの中に移り、もはやリアクティブではなくなりました。コードの実行は、ユーザーがフォームを送信したときだけです。詳しくは、「リアクティブな値とロジック」および「React + TypeScript: エフェクト(useEffect)を使わなくてよい場合とは」をご参照ください。

エフェクトが関係のない複数のことを実行していないか

エフェクトが複数のことを実行しているとき、それらは互いに関係あるのか考えるべきですす。

今度は、配送フォームをつくるとしましょう。ユーザが選ぶのは都市とエリアです。先に選択されたcountryに応じてサーバーからリストのcitiesを取得し、ドロップダウンで表示します。

function ShippingForm({ country: Country }) {
	const [cities, setCities] = useState<City[] | null>(null);
	const [city, setCity] = useState<City | null>(null);

	useEffect(() => {
		let ignore = false;
		fetch(`/api/cities?country=${country}`)
			.then(response => response.json())
			.then(json => {
				if (!ignore) {
					setCities(json);
				}
			});
		return () => {
			ignore = true;
		};
	}, [country]); // ✅ OK: すべての依存が宣言

}

このコードはエフェクトでデータを取得する適切な例です。状態citiesは、プロパティcountryに応じて、ネットワークと同期させています。このデータの取得はイベントハンドラではできません。ShippingFormコンポーネントが表示されたらすぐ、しかもcountryを変更するたびに実行しなければならないからです(どのようなユーザー操作にもとづくかは問いません)。

つぎに加えるのは、都市エリアのセレクトボックスです。現在選ばれているcityに対するareasを取得します。エリア一覧を得るふたつめのfetch呼び出しを、同じエフェクトに含めようと考えるかもしれません。

function ShippingForm({ country: Country }) {

	const [city, setCity] = useState<City | null>(null);
	const [areas, setAreas] = useState<Area[] | null>(null);

	useEffect(() => {
		let ignore = false;
		fetch(`/api/cities?country=${country}`)

		;
		// 🔴 NG: ひとつのエフェクトでふたつの異なる処理を行っている
		if (city) {
			fetch(`/api/areas?city=${city}`)
				.then(response => response.json())
				.then(json => {
					if (!ignore) {
						setAreas(json);
					}
				});
		}

	// }, [country]);
	}, [country, city]); // ✅ OK: すべての依存が宣言

}

エフェクトが状態変数cityを用いるようになったので、依存配列に加えました。これが問題となります。ユーザーが異なる都市を選ぶと、エフェクトは再実行され、fetchCities(country)も呼び出されてしまうからです。その結果、都市リストが何度も無駄に再取得されます。

このコードの問題は、ふたつの関係のない同期をしていることです

  1. 状態citiesをプロパティcountry にもとづいてネットワークと同期。
  2. 状態areascityにもとづいてネットワークと同期。

ロジックはふたつに分けてください。そして、それぞれが同期すべき値にのみ反応すればよいのです。

function ShippingForm({ country: Country }) {
	const [cities, setCities] = useState<City[] | null>(null);
	useEffect(() => {
		let ignore = false;
		fetch(`/api/cities?country=${country}`)
			.then(response => response.json())
			.then(json => {
				if (!ignore) {
					setCities(json);
				}
			});
		return () => {
			ignore = true;
		};
	}, [country]); // ✅ OK: すべての依存が宣言

	const [city, setCity] = useState<City | null>(null);
	const [areas, setAreas] = useState<Area[] | null>(null);
	useEffect(() => {
		if (city) {
			let ignore = false;
			fetch(`/api/areas?city=${city}`)
				.then(response => response.json())
				.then(json => {
					if (!ignore) {
						setAreas(json);
					}
				});
			return () => {
				ignore = true;
			};
		}
	}, [city]); // ✅ OK: すべての依存が宣言

}

これで、コードが再実行されるのは、1番目のエフェクトはcountryの変更時、2番目のエフェクトはcityの変更時のみです。コードは目的によって分けられました。ふたつの異なる同期は、ふたつの別々のエフェクトによって実行されるのです。ふたつの別個のエフェクトは、それぞれ異なる依存配列をもちます。互いを意図せず実行することはありません。

書き直したコードは長くなりました。けれど、エフェクトを分けたのは適切です。それぞれのエフェクトは、独立した同期の処理を表さなければなりません。前掲コード例で、一方のエフェクトを削除しても、もう一方のエフェクトのロジックは壊れません。つまり、それぞれは異なる同期だということです。そうであれば、分けるのが適切です。コードの重複が気になる場合は、繰り返しのロジックをカスタムフックに切り出すことで改善できます。

読み込んだ状態からつぎの状態を算出していないか

つぎのエフェクトが更新するのは、状態変数messagesです。新しいメッセージが届くたびに、新たにつくった配列で値を改めます。

function ChatRoom({ roomId: string }) {
	const [messages, setMessages] = useState([]);
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		connection.on('message', (receivedMessage) => {
			setMessages([...messages, receivedMessage]);
	});

}

状態変数messagesを更新するために用いられているのは設定関数setMessagesです。新たな配列をつくるため、スプレッド構文...で展開したmessagesの現行値に新規のメッセージ(receivedMessage)を加えて引数の配列としました。ここで、messagesはエフェクトが読み込むリアクティブな値ですので、依存配列に加えなければなりません。

function ChatRoom({ roomId: string }) {
	const [messages, setMessages] = useState([]);
	useEffect(() => {

		connection.on('message', (receivedMessage) => {
			setMessages([...messages, receivedMessage]);
		});
		return () => connection.disconnect();
	}, [roomId, messages]); // ✅ OK: すべての依存が宣言

}

けれど、messagesを依存値に含めるのは問題です。

メッセージを受け取るたび、setMessagesがコンポーネントの再レンダーを引き起こしします。状態変数messagesの配列に新たに含められるのは、受け取ったメッセージです。ところが、エフェクトはmessagesに依存しています。そのため、エフェクトが再同期されるのです。つまり、新しいメッセージが届くたびに、チャットは再接続されることになります。これは、ユーザーにとって好ましいとはいえません。

解決するには、エフェクトの中でmessagesを読み取らないことです。替わりに、setMessagesには更新関数を渡してください。

function ChatRoom({ roomId: string }) {
	const [messages, setMessages] = useState([]);
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		connection.on('message', (receivedMessage) => {
			// setMessages([...messages, receivedMessage]);
			setMessages((msgs) => [...msgs, receivedMessage]);
		});
		return () => connection.disconnect();
	// }, [roomId, messages]);
	}, [roomId]); // ✅ OK: すべての依存が宣言

}

これで、エフェクトは状態変数messagesを読み込みませんsetMessagesの引数に(msgs) => [...msgs, receivedMessage]という更新関数を渡したからです。Reactは更新関数をキューに入れます。そして、つぎのレンダー時に、状態変数messagesの現行値は、更新関数の引数msgsとして渡すのです。エフェクトそのものは、もはや状態変数messagesには依存しません。これで、問題は解消しました。チャットメッセージを受け取っても、チャットの再接続は起きなくなったのです。

変更に「反応」せずに値を読み出したいだけではないか

[注記] この項でご紹介するのは実験的なAPI(useEffectEvent)です。まだ、Reactの安定版にはリリースされていません

ユーザが新しいメッセージを受け取ったとき、音を再生するとしましょう。ただし、状態変数isMutedtrueでない場合です。

function ChatRoom({ roomId: string }) {
	const [messages, setMessages] = useState([]);
	const [isMuted, setIsMuted] = useState(false);

	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		connection.on('message', (receivedMessage) => {
			setMessages((msgs) => [...msgs, receivedMessage]);
			if (!isMuted) {
				playSound();
			}
		});

	});

}

エフェクトがコード内でisMutedを用いるようになったので、依存配列に加えなければなりません。

function ChatRoom({ roomId: string }) {

	useEffect(() => {

		connection.on('message', (receivedMessage) => {

			if (!isMuted) {
				playSound();
			}
		});
    	return () => connection.disconnect();
	}, [roomId, isMuted]); // ✅ OK: すべての依存が宣言

}

ここで問題が生じます。isMutedが(ユーザーによる「ミュート」トグルボタンのクリックなどで)変更されるたび、エフェクトは再同期され、チャットに再接続されてしまうのです。ユーザー体験として望ましくないでしょう。

解決するには、リアクティブでないロジックを、エフェクトの外に切り出すことです。エフェクトは、isMutedの変更に「反応」させたくありません。リアクティブでないロジックは、エフェクトイベントに移すのです(「experimental_useEffectEvent」参照)。

// import { useState, useEffect } from 'react';
import { useState, useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId: string }) {

	const onMessage = useEffectEvent((receivedMessage) => {
		setMessages((msgs) => [...msgs, receivedMessage]);
		if (!isMuted) {
			playSound();
		}
	});
	useEffect(() => {

		connection.on('message', (receivedMessage) => {
			/* setMessages((msgs) => [...msgs, receivedMessage]);
			if (!isMuted) {
				playSound();
			} */
			onMessage(receivedMessage);
		});
		return () => connection.disconnect();
	// }, [roomId, isMuted]);
	}, [roomId]); // ✅ OK: すべての依存が宣言

}

エフェクトイベントを用いれば、エフェクトはリアクティブな部分とリアクティブでない部分に分けられます。

  • リアクティブな部分: リアクティブな値とその変更に「反応」。
    • 前掲コード例のroomId
  • リアクティブでない部分: 最新の値を読み取るだけ。
    • 前掲コード例で、onMessageによるisMutedの読み込み。

isMutedはエフェクトイベント内で読み込まれるため、もはやエフェクトの依存値ではありません。その結果、チャットの再接続は、「ミュート」ボタンのオン/オフを切り替えても発生しなくなりました。

プロパティとして受け取ったイベントハンドラを包む

同じような問題は、コンポーネントがイベントハンドラをプロパティとして受け取った場合にも起こるかもしれません。

function ChatRoom({
	roomId: string,
	onReceiveMessage: (receivedMessage: string) => void
}) {

	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		connection.on('message', (receivedMessage) => {
			onReceiveMessage(receivedMessage);
		});
		return () => connection.disconnect();
	}, [roomId, onReceiveMessage]); // ✅ OK: すべての依存が宣言

}

イベントハンドラonReceiveMessageは親コンポーネントから受け取ります。すると、渡される関数はレンダーごとに異なるかもしれません。

<ChatRoom
	roomId={roomId}
	onReceiveMessage={(receivedMessage) => {

	}}
/>

onReceiveMessageはエフェクトの依存値です。すると、エフェクトの再同期が、親コンポーネントの再レンダーのたびに起こってしまいます。つまり、チャットも再接続されるということです。解決するためには、関数の呼び出しをエフェクトイベントで包みましょう。

function ChatRoom({
	roomId: string,
	onReceiveMessage: (receivedMessage: string) => void
}) {

	const onMessage = useEffectEvent(receivedMessage => {
		onReceiveMessage(receivedMessage);
	});
	useEffect(() => {

		connection.on('message', (receivedMessage) => {
			// onReceiveMessage(receivedMessage);
			onMessage(receivedMessage);
		});

	// }, [roomId, onReceiveMessage]);
	}, [roomId]); // ✅ OK: すべての依存が宣言

}

エフェクトイベントはリアクティブではありません。エフェクトの依存配列には含めなくて済むのです。結果として、チャットの再接続は、親コンポーネントの渡す関数が再レンダーごとに変わっても、もはや発生しません。

リアクティブなコードと非リアクティブなコードを分ける

つぎのコードは、roomIdが変わるたびに訪問をログに記録する例です。各ログにはnotificationCountの現行値を含めます。ただし、notificationCountの値を変更しても、ログイベントによる記録は実行したくありません。このような場合も、非リアクティブなコードはエフェクトイベントに切り出しましょう。

function Chat({ roomId: string, notificationCount: number }) {
	const onVisit = useEffectEvent(visitedRoomId => {
		logVisit(visitedRoomId, notificationCount);
	});
	useEffect(() => {
		onVisit(roomId);
	}, [roomId]); // ✅ OK: すべての依存が宣言

}

ロジックはroomIdについてはリアクティブです。したがって、エフェクトの中で読み込みました。けれど、notificationCountの変更により、余計な訪問ログを記録したくはありません。したがって、notificationCountは、エフェクトイベント内で読み取ったのです(「Reading latest props and state with Effect Events」参照)。

リアクティブな値が意図せず変更されていないか

エフェクトに「反応」してほしい特定の値が、意図した以上に頻繁に変更されてしまう場合もあります。ユーザーから見て実際に反映されるべき変化がないにもかかわらずです。たとえば、つぎのChatRoomは、コンポーネント本体でoptionsオブジェクトをつくっています。そのオブジェクトを読み取っているのはエフェクトです。

function ChatRoom({ roomId: string }) {

	const options = { serverUrl, roomId };
	useEffect(() => {
		const connection = createConnection(options);
		connection.connect();

	});

}

オブジェクトはコンポーネント本体の中に宣言しました。したがって、リアクティブな値です。エフェクトからリアクティブな値を読み取るには、依存配列に加えなければなりません。これにより、エフェクトはその値の変更に「反応」できるのです。

useEffect(() => {
	const connection = createConnection(options);
	connection.connect();
	return () => connection.disconnect();
}, [options]); // ✅ OK: すべての依存が宣言

依存値は正しく宣言してください。これにより、たとえばroomIdが変わったとき、エフェクトは新たなoptionsにもとづいて、チャットに確実に再接続できるのです。ただし、このコードには問題があります。確かめるために、テキスト入力フィールドを加えてみました(サンプル003)。

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId }) => {
	const [message, setMessage] = useState("");
	const options = { serverUrl, roomId };
	useEffect(() => {
		const connection = createConnection(options);

	}, [options]);
	return (
		<>

			<input
				value={message}
				onChange={({ target: { value } }) => setMessage(value)}
			/>
		</>
	);
};

サンプル003■React + TypeScript: Removing Effect Dependencies 03

<input>要素への入力が更新するのは、状態変数messageです。すると、テキストフィールドに入力するたび、コンポーネントは再レンダーされてしまいます。エフェクトのコードも再実行され、チャットの再接続を引き起こしてしまうのです。ユーザーの視点から問題というべきでしょう。

optionsオブジェクトは、ChatRoomコンポーネントが再レンダーされるたび新たにつくり直されるからです。つまり、Reactが認識するoptionsオブジェクトは、前のレンダーでつくったオブジェクトと同じではありません。そのため、(optionsに依存する)エフェクトは再同期され、テキストフィールドにタイプするたびチャットが再接続されたのです。

ただし、オブジェクトと関数以外ではこの問題は生じませんJavaScriptでは、新たにつくられたオブジェクトや関数はすべて互いに異なるものと扱われますコードの記述が同じかどうかは問いません

そのため、オブジェクトや関数を依存配列に含めると、エフェクトの再同期が意図したより頻繁に起こってしまうのです

オブジェクトと関数をエフェクトの依存配列に加えるのは、できるかぎり避けましょう。そのときオブジェクトや関数の扱い方として考えられるのは、つぎの対応です。

  • コンポーネントの外に切り出す。
  • エフェクトの中に含める。
  • プリミティブ値を取り出す。

静的なオブジェクトや関数をコンポーネントの外に切り出す

オブジェクトがプロパティや状態に依存していなければ、コンポーネントの外に切り出して構いません。

たとえば、つぎのコードではcreateConnectionの引数には、serverUrlroomIdのふたつが渡されています。けれど、リンターからserverUrlを依存配列に含めるようにという警告は示されません。コンポーネントの外に宣言されて、再レンダーしても変わらないため、依存しないことがわかるからです。

const serverUrl = "https://localhost:1234";
function ChatRoom({ roomId: string }) {

	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);

	}, [roomId]); // ✅ OK: すべての依存が宣言

}

前掲コード例でも、変数optionsをコンポーネントの外に宣言してしまえば、エフェクトは依存しなくなります。ChatRoomを再レンダーしてもエフェクトは再同期しなくなり、チャットの再接続も起こりません。ただし、値(roomId)が変わらないのですから、チャットルームはドロップダウンで切り替えられなくなるでしょう。

src/ChatRoom.tsx
const options = {
	serverUrl: 'https://localhost:1234',
	roomId: 'music'
};
export const ChatRoom: FC<Props> = ({ roomId }) => {

	// const options = { serverUrl, roomId };
	useEffect(() => {
		const connection = createConnection(options);

	// }, [options]);
	}, []); // ✅ OK: すべての依存が宣言

};

関数についても、同じようにコンポーネントの外に切り出せば、エフェクトの依存から外せます。なお、JavaScriptでは、関数もまたオブジェクトです(「関数」参照)。

リアクティブなオブジェクトや関数をエフェクトの中に含める

オブジェクトがリアクティブな値に依存し、再レンダーの結果変わるかもしれなければ、コンポーネントの外には切り出せません。どう対処すべきかは、リンターが教えてくれます。前掲サンプル003では、以下のようにリンターをあえて停止していました。このコメントを削除してください。つぎのような警告が示されるはずです。

The 'options' object makes the dependencies of useEffect Hook change on every render. Move it inside the useEffect callback. Alternatively, wrap the initialization of 'options' in its own useMemo() Hook.

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId }) => {

	// リンターの警告は一時的に止めて問題を確認
	// eslint-disable-next-line react-hooks/exhaustive-deps
	const options = { serverUrl, roomId };
	useEffect(() => {

	}, [options]);

};

optionsオブジェクトがuseEffectフックの依存をレンダーのたびに変更すると告げています。避ける方法のひとつが、オブジェクトをuseEffectのコールバック内に移すことです(もうひとつのuseMemoフックについては、本稿では省きます)。

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId }) => {

	// const options = { serverUrl, roomId };
	useEffect(() => {
		const options = { serverUrl, roomId };

	// }, [options]);
	}, [roomId]); // ✅ OK: すべての依存が宣言

};

エフェクトの内部に宣言したoptionsは、もはや依存値ではありません。替わってエフェクトに用いられるリアクティブな値がroomIdです。roomIdはオブジェクト(関数を含む)ではありません。コンポーネントの再レンダーだけで、意図せず変わったりはしないということです。JavaScriptで数値や文字列(プリミティブ)は、記述された値により比較されます。

これで、テキストフィールドに入力しても、チャットの再接続は起こりません。他方で、ドロップダウンの選択を替えれば、選ばれたチャットルームに接続されるようになりました(サンプル004)。

サンプル004■React + TypeScript: Removing Effect Dependencies 04

関数でも同じです。関数を活用すれば、エフェクトの中のロジックがまとめられます。エフェクト内で宣言しているかぎり、関数はリアクティブな値にはなりません。したがって、エフェクトの依存配列には含めなくてよいのです。

src/
export const ChatRoom: FC<Props> = ({ roomId }) => {

	useEffect(() => {
		const createOptions = () => ({ serverUrl, roomId });
		// const options = { serverUrl, roomId };
		const options = createOptions();

	}, [roomId]);

};

オブジェクトからプリミティブ値を読み取る

オブジェクトをプロパティで受け取る場合について考えましょう。

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ options }) => {

	useEffect(() => {
		const connection = createConnection(options);

	}, [options]); // ✅ OK: すべての依存が宣言

};

このとき、親コンポーネントがオブジェクトをレンダリング中につくっているかもしれません。

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

	return (
		<>

			<ChatRoom options={{ serverUrl, roomId }} />
		</>
	);
}

すると、親コンポーネントが再レンダーされるたびに、子のChatRoomコンポーネントのエフェクトはチャットを再接続することになります。これを避けるには、オブジェクトの情報をエフェクトの外で読み取ることです。そして、オブジェクトや関数を依存値には持たせません。

src/
export const ChatRoom: FC<Props> = ({ options: { serverUrl, roomId } }) => {

	useEffect(() => {
		// const connection = createConnection(options);
		const connection = createConnection({ serverUrl, roomId });

		// }, [options]);
	}, [serverUrl, roomId]); // ✅ OK: すべての依存が宣言

};

親コンポーネントがプロパティに渡すオブジェクトをつくり直したとしても、それだけではチャットの再接続は起こりません。オブジェクト内の依存値が変わったとき、再接続されるのです。

関数からプリミティブ値を算出する

関数でも同じように考えられます。親コンポーネントがプロパティとして渡す関数から、オブジェクトを返してみましょう。

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

	return (
		<>

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

関数が依存値に含まれない(毎レンダー再接続しない)ように、エフェクトの外で戻り値からプリミティブ値を読み取ります。エフェクト内で用いるのは、取り出したリアクティブな値です。

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ getOptions }) => {

	const { serverUrl, roomId } = getOptions();
	useEffect(() => {
		const connection = createConnection({ serverUrl, roomId });

	}, [serverUrl, roomId]);

};

この手法は、純粋な関数にのみ使えます。レンダリング中に安全に呼び出せるからです。関数がイベントハンドラで、変更をエフェクトと再同期したくない場合には、エフェクトイベントで包んでください

まとめ

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

  • 依存配列は、つねにコードと合致しなければなりません。
  • 依存配列が意図にそぐわないとき、修正すべきなのはコードです。
  • リンターの警告を止めると、わかりにくいバグが生じやすいので、避けてください。
  • 依存値を除くには、リンターに値が不要だと「証明」しなければなりません。
  • コードが特定のユーザー操作に応じて実行されるべき場合は、イベントハンドラの中に置いてください。
  • エフェクトの中に異なる理由で再実行されるコードがあるときは、エフェクトを分けるべきでしょう。
  • 状態をその前の状態にもとづいて更新したい場合には、状態設定関数の引数に更新関数を渡します。
  • 最新の値を「反応」はさせずに読み取りたいとき、エフェクトからコードの切り出しに用いるのがエフェクトイベントです。
  • JavaScriptでは、新たにつくられるオブジェクトと関数は、すべて異なる値だとみなされます。
  • オブジェクトと関数はなるべく依存に含めないようにしてください。コードを移す先は、コンポーネントの外、もしくはエフェクトの中です。
2
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?