5
3

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: リアクティブなエフェクト(useEffect)のライフサイクル

Last updated at Posted at 2022-09-14

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

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

本稿は基本的に記事の情報は網羅しているものの、邦訳ではありません。足りない部分は補ったり、説明の仕方を改めたり、不要と思われる記述は削除しました。また、公式サイトと異なり、サンプルコードはモジュール分けし、さらにTypeScriptも導入しています。

エフェクトとコンポーネントとは異なるライフサイクルです。

  • コンポーネント
    • マウント
    • 更新
    • アンマウント
  • エフェクト
    • 同期の開始
    • 同期の停止

エフェクトの依存するプロパティや状態が時間とともに変化する場合、ライフサイクルは繰り返されるかもしれません。Reactの提供するリンタールールにより、エフェクトに正しく依存が定められているか確かめられます。こうして、エフェクトは最新のプロパティや状態と同期されるのです。

エフェクトのライフサイクル

Reactのコンポーネントは、みな同じライフサイクルをたどります。

  • マウント: 画面に加えられたとき。
  • 更新: 新たなプロパティや状態を受け取ったとき。
    • インタラクションへの反応でよく起こる。
  • アンマウント: 画面から除かれたとき。

コンポーネントについては、このように捉えて構いません。けれど、エフェクトは違います。エフェクトのライフサイクルは、コンポーネントとは切り離して考えなければならないのです。エフェクトには、外部のシステムとプロパティや状態との同期の仕方を記述します。コードが変更されると、同期する頻度は変わってくるでしょう。

つぎのコードは、エフェクトがコンポーネントをチャットサーバーに接続する例です。

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

useEffectの引数のコールバック関数本体は、同期をどのように開始するか定めます。

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

useEffectの引数コールバック関数が返すのはクリーンアップ関数です。同期をどのように終了するか定めます。

() => {
	// ...
	return () => connection.disconnect();
}

少し見ただけですと、コンポーネントがマウントされると同期を始め、アンマウントされたら終わると捉えるかもしれません。けれど、それだけでは済まないのです。コンポーネントはマウントされたまま、同期の開始と停止を繰り返さなければならないこともあります。

同期の開始と停止がなぜ必要なのか、どのようなとき起こるのか、およびその処理をどう扱うのか見てゆきましょう。

[注記] エフェクトによってはクリーンアップ関数を返しません。もっとも、返した方がよい場合は多いです。戻り値がないと、Reactは空のクリーンアップ関数を返したかのように動作します。

なぜ同期が複数回発生しなければならないのか

ChatRoomコンポーネントが、つぎのようにroomIdを受け取るとしましょう。プロパティの値は、ユーザーがドロップダウンから選びます。はじめにユーザーは、"general"の部屋をroomIdとして選びました。アプリケーションが表示するのは、チャットルーム"general"です。

export const ChatRoom: FC<Props> = ({ roomId /* "general" */, serverUrl }) => {
	// ...
	return <h1>Welcome to the {roomId} room!</h1>;
};

UIが表示された後、Reactはエフェクトの実行により同期を開始します。そして、部屋"general"に接続です。

export const ChatRoom: FC<Props> = ({ roomId /* "general" */, serverUrl }) => {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId); // 部屋"general"に接続
		connection.connect();
		return () => connection.disconnect(); // 部屋"general"から切断
	}, [roomId, serverUrl]);
	// ...
};

ここまでは問題ありません。

そのあと、ユーザーはドロップダウンで別の部屋(たとえば"travel")を選びました。まず、ReactはUIを更新します。

export const ChatRoom: FC<Props> = ({ roomId /* "travel" */, serverUrl }) => {
	// ...
	return <h1>Welcome to the {roomId} room!</h1>;
};

問題は、つぎに何が起こるかです。

ユーザーは、選択した部屋が"travel"に変わったことをUIで確かめます。ただし、前回実行したエフェクトは、"general"に接続したままです。プロパティroomIdの値は変わりました。前回のエフェクト(部屋"general"に接続)とUIが一致しなくなったのです。

ここで、Reactに実行してほしいことはつぎのふたつでしょう。

  1. 古いroomIdとの同期停止: 部屋"general"から切断する。
  2. 新しいroomIdとの同期開始: 部屋"travel"と接続する。

実は、これらの設定はすでにReactに加えてあります。useEffectに与えた引数コールバック関数の役割はつぎのとおりでした。

  • 関数本体: 同期をどのように開始するか。
  • 関数の戻り値: 同期をどのように停止するか。

あとは、Reactが正しい順序で、正しいプロパティと状態をもとに実行すればよいだけです。具体的に見ていきましょう。

Reactがエフェクトをどのように再同期するか

ChatRoomコンポーネントは、roomIdプロパティの新たな値を受け取ったのでした。前の値は"general"だったものが、今は"travel"です。Reactはエフェクトを再同期して、別の部屋に接続し直さなければなりません。

同期を停止するために、Reactはクリーンアップ関数を呼び出します。その関数は、部屋"general"に接続したあとの戻り値です。roomId"general"でしたから、クリーンアップ関数は部屋"general"から切断します。

export const ChatRoom: FC<Props> = ({ roomId /* "general" */, serverUrl }) => {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId); // 部屋"general"に接続
		connection.connect();
		return () => connection.disconnect(); // 部屋"general"から切断
	// ...
};

つぎに、Reactは今回のレンダリング時に与えられたエフェクトを実行するのです。今のroomId"travel"ですから、"travel"チャットルームとの同期が開始されます(最終的に戻り値のクリーンアップ関数が呼び出されるまで)。

export const ChatRoom: FC<Props> = ({ roomId /* "travel" */, serverUrl }) => {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId); // 部屋"travel"に接続
		connection.connect();
	// ...

これで、接続している部屋はユーザーがUIで選んだIDと一致しました。

コンポーネントが異なるroomIdの値で再レンダリングされるたびに、エフェクトは再同期されるのです。たとえば、ユーザーがroomId"travel"から"music"に変えたとしましょう。Reactは前のエフェクトのクリーンアップ関数を呼び出して、同期が停止されます(部屋"travel"から切断)。そしてつぎは、新たな同期の開始です。新しいroomIdプロパティの値"music"でエフェクトのコールバック関数本体が呼び出されます(部屋"music"に接続)。

最後に、ユーザーが別の画面に遷移すると、ChatRoomコンポーネントはアンマウントです。もはや、接続し続ける必要はありません。Reactは最後のエフェクトを停止して、チャットルーム"music"から切断するのです。

エフェクトの視点で考える

ChatRoomで起こったことを、コンポーネントの観点でまとめてみましょう。

  1. ChatRoomroomIdの値"general"でマウント。
  2. ChatRoomroomIdの値"travel"で更新。
  3. ChatRoomroomIdの値"music"で更新。
  4. ChatRoomをアンマウント。

コンポーネントのライフサイクルの各段階で、エフェクトが実行したことはつぎのとおりです。

  1. エフェクトが部屋"general"に接続。
  2. エフェクトが部屋"general"から切断して、部屋"travel"に接続。
  3. エフェクトが部屋"travel"から切断して、部屋"music"に接続。
  4. エフェクトが部屋"music"から切断。

ChatRoomで起こったことを、改めてエフェクトの観点からまとめます。

useEffect(() => {
	// 与えられたroomIdの値で接続
	const connection = createConnection(serverUrl, roomId);
	connection.connect();
	return () => connection.disconnect(); // クリーンアップで切断
}, [roomId, serverUrl]);

コードの構造から捉えれば、つぎのとおりです。

  1. エフェクトが部屋"general"に接続。
    • クリーンアップで切断するまで。
  2. エフェクトが部屋"travel"に接続。
    • クリーンアップで切断するまで。
  3. エフェクトが部屋"music"に接続。
    • クリーンアップで切断するまで。

先にエフェクトの実行を、コンポーネントの観点で考えました。すると、「レンダリング後」とか「アンマウント前」など特定のタイミングが絡まざるをえません。「コールバック」や「ライフサイクルイベント」のように捉えることになりがちです。複雑になるだけなので、避けた方がよいでしょう。

単純に、ひとつのエフェクトの中の開始と停止のサイクルだけ考えればよいのです。コンポーネントのマウントや更新、アンマウントとは切り離してください。エフェクトに書くのは、同期をどう開始し、どう停止するかだけです。それさえうまく記述すれば、エフェクトは必要に応じて何度でも開始および停止できます。

これは、コンポーネントのマウントや更新、アンマウントは気にせずに、JSXのレンダリングロジックがつくれるのと似ているでしょう。画面に何を表示するかさえ記述すれば、あとはReactが判断してくれるのです。

Reactはエフェクトが再同期できることをどう検証するか

つぎのサンプル001は、CodeSandboxに公開したChatRoomコンポーネントの作例です。[Open chat]と[Close chat]のボタンで、接続と切断の動きを[Console]の出力でお確かめください。

サンプル001■React + TypeScript: Lifecycle of Reactive Effects 01

はじめに[Open chat]ボタンでコンポーネントをマウントしたとき、3つの[Console]出力が示されることに気づくでしょう。

  1. ✅ Connecting to "general" room at https://localhost:1234... (開発時のみ)
  2. ❌ Disconnected from "general" room at https://localhost:1234. (開発時のみ)
  3. ✅ Connecting to "general" room at https://localhost:1234...

はじめのふたつのログが表示されるのは、開発時のみです。開発中、Reactは各コンポーネントをつねに1度だけマウントし直します。つまり開発に際して、はじめてのマウント後すぐ強制的に再マウントすることにより、エフェクトが再同期できることを確かめるのです。ドアを開けたらいったん閉めてみて、ロックがかかることを確かめたうえで改めて開くのと似ているかもしれません。Reactが開発時、はじめに開始したエフェクトを1度停止してみるのは、クリーンアップが正しく実装されたことを検証するためなのです。

実際にエフェクトが再同期しなければならないのは、使用するデータが変更された場合です。前掲サンプル001で、たとえばチャットルームの選択を変えてみてください。roomIdが変更されるので、エフェクトは再同期されるのです。

エフェクトの再同期が必要となる例には、まれな場合もあります。たとえば、前掲サンプル001で[Open chat]ボタンで接続したあと、モジュールsrc/App.tsxのコードのserverUrlを編集してみてください(反映されるまでに少し時間がかかるかもしれません)。コードの変更に応じて、エフェクトが再同期されるはずです。将来、Reactには再同期を利用した機能が加わるかもしれません。

前掲サンプル001のおもな4つのモジュールの記述は、つぎのコード001のとおりです。src/chat.tsのコードはあくまで確認用で、実際にはサーバーとの接続が実装されることになります。

コード001■おもな4つのモジュールの記述

src/App.tsx
import { useState } from 'react';
import { ChatRoom } from './ChatRoom';
import { RoomSelector } from './RoomSelector';
import './styles.css';

export type Room = { id: number; name: string };
const rooms: Room[] = ['general', 'travel', 'music'].map((room, index) => ({
	id: index,
	name: room
}));
const serverUrl = 'https://localhost:1234';
export default function App() {
	const [roomId, setRoomId] = useState(rooms[0].name);
	const [show, setShow] = useState(false);
	return (
		<div className="App">
			<RoomSelector roomId={roomId} setRoomId={setRoomId} rooms={rooms} />
			<button onClick={() => setShow(!show)}>
				{show ? 'Close chat' : 'Open chat'}
			</button>
			{show && <hr />}
			{show && <ChatRoom roomId={roomId} serverUrl={serverUrl} />}
		</div>
	);
}
src/ChatRoom.tsx
import { FC, useEffect } from 'react';
import { createConnection } from './chat';

type Props = {
	roomId: string;
	serverUrl: string;
};
export const ChatRoom: FC<Props> = ({ roomId, serverUrl }) => {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		return () => connection.disconnect();
	}, [roomId, serverUrl]);
	return <h1>Welcome to the {roomId} room!</h1>;
};
src/RoomSelector.tsx
import { FC } from 'react';
import type { Room } from './App';

type Props = {
	roomId: string;
	rooms: Room[];
	setRoomId: (id: string) => void;
};
export const RoomSelector: FC<Props> = ({ roomId, rooms, setRoomId }) => {
	return (
		<label>
			Choose the chat room:{' '}
			<select
				value={roomId}
				onChange={(event) => setRoomId(event.target.value)}
			>
				{rooms.map((room) => {
					return (
						<option key={String(room.id)} value={room.name}>
							{room.name}
						</option>
					);
				})}
			</select>
		</label>
	);
};
src/chat.ts
export const createConnection = (serverUrl: string, roomId: string) => {
	// 実際にはサーバーとの接続を実装
	return {
		connect() {
			console.log(
				`✅ Connecting to "${roomId}" room at ${serverUrl}...`
			);
		},
		disconnect() {
			console.log(`❌ Disconnected from "${roomId}" room at ${serverUrl}`);
		}
	};
};

Reactはエフェクトを再同期すべきことはどうやって知るか

Reactは、エフェクトを再同期すべきだと、roomIdが変わったときどのようにして知るのでしょうか。エフェクトのコードがroomIdに依存することを、依存配列に加えてReactに伝えてあったからです。

src/ChatRoom.tsx
// コンポーネントが引数として受け取ったプロパティroomIdとserverUrlは変化する
export const ChatRoom: FC<Props> = ({ roomId, serverUrl }) => {
	useEffect(() => {
		// エフェクトがふたつのプロパティを読み込んで用いる
		const connection = createConnection(serverUrl, roomId);		connection.connect();
		return () => connection.disconnect();
	}, [roomId, serverUrl]); // エフェクトがふたつのプロパティに依存することをReactに伝える
	// ...
};

つぎのように分けて考えられます(プロパティserverUrlについても同じです)。

  1. roomIdはプロパティです。
    • 時間によって値が変わるかもしれません。
  2. エフェクトはroomIdを読み込みます。
    • ロジックはのちのち変わる値に依存します。
  3. そのためエフェクトの依存として定めます。
    • roomIdの値が再同期します。

コンポーネントの再レンダリングが済むたび、Reactはエフェクトに与えられた依存配列の中を調べます。配列要素のいずれかが、前回のレンダリング時同じ箇所で確かめた値と異なるとき、Reactはエフェクトを再同期させるのです。たとえば、依存配列[roomId]のプロパティ値として、はじめのレンダリング時に"general"を渡し、そのあと"travel"に書き替えたとしましょう。Reactは、値"general""travel"とを比べます。ふたつは(Object.isによる比較で)異なる値です。そこで、Reactはエフェクトを再同期します。けれど、コンポーネントを再レンダリングしてもroomIdが変わらなければ、エフェクトの接続先は同じ部屋のままです。

各エフェクトは別個の同期処理を表す

ロジックは論理的に分けてください。すでに書いたエフェクトと実行時期が同じだからといって、他のロジックを詰め込むべきではありません。たとえば、分析のイベントを送りたいのが、ユーザーの部屋への訪問時だとします。すでに、roomIdに依存するエフェクトがあります。分析イベントの呼び出しも、そこに加えたらよいと思うかもしれません。

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId, serverUrl }) => {
	useEffect(() => {
		logVisit(roomId); // 分析イベントの呼び出し
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		return () => connection.disconnect();
	}, [roomId, serverUrl]);
	// ...
};

けれど、あとでエフェクトに別の依存が加わわり、接続を再確立しなければならなくなったらどうでしょう。エフェクトが再同期すると、logVisit(roomId)は同じ部屋に対して呼び出されてしまいます。これは意図した結果ではありません。訪問のログは、接続とは別の処理です。そのため、ふたつの別個のエフェクトとして記述しなければなりません。

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId, serverUrl }) => {
	useEffect(() => {
		logVisit(roomId); // 分析イベントの呼び出し
	}, [roomId]);
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		// ...
	}, [roomId, serverUrl]);
	// ...
};

コードにおける各効果は、それぞれ独立した同期処理を記述すべきです

上記コード例では、一方のエフェクトを削っても、他方に影響は及びません。このことが示すのは、ふたつの同期しているものが異なるということです。そのため、ふたつのエフェクトは分けました。他方で、ひとつのまとまったロジックを別々のエフェクトに分けてしまうと、コードの見た目はすっきりするかもしれません。けれど、メンテナンスが難しくなります

エフェクトは「リアクティブ」な値に反応する

ChatRoomのエフェクトは、引数のコールバック関数本体で、コンポーネントが受け取ったふたつのプロパティをcreateConnection()に渡して呼び出しました。そのため、依存配列にはふたつのプロパティ[roomId, serverUrl]が与えられているのです。

ところが、本稿が参照したReact公式サイト「Lifecycle of Reactive Effects」のコード例では、つぎのようにroomIdしか依存に含めていません(「Effects “react” to reactive values」参照)。

const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		return () => {
			connection.disconnect();
		};
	}, [roomId]);
	// ...
}

これは、関数ChatRoomの外に宣言された変数serverUrlは、コンポーネントを再レンダリングしても変更されないからです。新たなプロパティや状態にもとづいて、何度レンダリングが行われても値は変わりません。つねに同じ値のserverUrlを依存に加えても意味がないのです。依存関係は変化する値に応じてエフェクトを実行します。

プロパティや状態、およびコンポーネント内で宣言された値はリアクティブです。リアクティブな値は、レンダリング中に演算され、Reactのデータフローに加わります。

もっとも、本稿コード例でも、変数serverUrlはコンポーネントAppの外で宣言されていました。ですから、Appから見てserverUrlの値は不変です。けれど、ChatRoomに渡されるプロパティserverUrlの値は、あとでAppによって変えられるかもしれません。つまり、ChatRoomコンポーネントが受け取るserverUrlプロパティはリアクティブなのです。

src/App.tsx
const serverUrl = 'https://localhost:1234';
export default function App() {
	// ...
	return (
		<div className="App">
			{/* ... */}
			{show && <ChatRoom roomId={roomId} serverUrl={serverUrl} />}
		</div>
	);
}

前述のとおり、状態もリアクティブです。したがって、エフェクトでその値を使っていたら、依存に含めなければなりません。

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId, initUrl }) => {
	const [serverUrl, setServerUrl] = useState(initUrl); // 状態は変わりえる
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		return () => connection.disconnect();
	}, [roomId, serverUrl]); // プロパティと状態は依存に含める
	// ...
};

つぎのサンプル002では、テキスト入力フィールドの[Server URL]を編集すると、serverUrlの値が改められて再同期されます。

サンプル002■React + TypeScript: Lifecycle of Reactive Effects 02

roomIdserverUrlのどちらの値が変わっても、エフェクトはサーバーに再接続するのです。

空の依存関係とは

エフェクトがリアクティブな値を使わない場合には、空の依存配列[]を渡します(サンプル003)。

src/ChatRoom.tsx
const serverUrl = "https://localhost:1234";
const roomId = "general";
export const ChatRoom: FC = () => {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		return () => connection.disconnect();
	}, []); // 依存関係がない
	// ...
};

コンポーネントの観点から捉えれば、空の依存配列[]が示すのはエフェクトはつぎのふたつの場合にだけ実行されるということです(開発時、Reactがエフェクトのロジックを再同期して確かめることについては、前述「Reactはエフェクトが再同期できることをどう検証するか」参照)。

  • コンポーネントのマウント時: チャットルームに接続する。
  • コンポーネントのアンマウント時: チャットルームから切断する。

サンプル003■React + TypeScript: Lifecycle of Reactive Effects 03

けれど、エフェクトの観点に立つなら、マウントとアンマウントについてはまったく気にしなくて構いません(前述「エフェクトの視点で考える」参照)。大切なのは、エフェクトが同期の開始時と停止時に何をするか定めることです。先ほどは、リアクティブな依存関係はなくしました。改めて、roomIdserverUrlが変更される(つまりリアクティブにする)ようになった場合も、エフェクトの引数コールバックのコードは変わりません。依存配列にそれらの値を加えるだけです。

コンポーネント本体に宣言された変数はすべてリアクティブ

プロパティと状態だけがリアクティブな値ではありません。それらから算出した値もまたリアクティブです。プロパティや状態が変わると、コンポーネントは再レンダリングされます。すると、それらから演算される値も改められるのです。したがって、エフェクトで用いられるコンポーネント本体の変数もすべて、依存配列に含めなければなりません。

チャットサーバーがドロップダウンから選べるコード例で、デフォルトサーバーを設定に加えてみましょう。設定はコンテクスト(SettingsContext)に入れました(「Scaling Up with Reducer and Context」参照)。したがって、コンテクストから状態settingsを読み取ればよいのです。そして、プロパティで受取った選択サーバーselectedServerUrlとコンテクストのデフォルトサーバーsettings.defaultServerUrlからserverUrlが決まります。

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId, selectedServerUrl }) => {
	const settings = useContext(SettingsContext);
	const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrlはリアクティブ
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		return () => connection.disconnect();
	}, [roomId, serverUrl]);
	// ...
};

このコード例で、serverUrlはプロパティでも状態変数でもありません。レンダリング中に演算される通常の変数です。けれど、レンダリング時に算出されるということは、値が変わるかもしれません。そのため、リアクティブと評価されます。

コンポーネント内のつぎの値はすべてリアクティブです

  • 受取ったプロパティ
  • 状態
  • 本体の変数

リアクティブな値は、再レンダリングで変更されるかもしれません。そのため、エフェクトのコールバック関数本体で用いられたときは、依存に含めなければならないのです。

つまり、エフェクトはコンポーネント本体のすべての値に「リアクト」します。

グローバルあるいはミュータブルな値は依存関係になるか

ミュータブルな値(グローバル値を含む)はリアクティブではありません。

location.pathnameのようなミュータブルな値は、依存関係には含められないのです。変更可能ということは、Reactのレンダリングデータフローのまったく外からいつでも変えられます。しかも、変更されたときコンポーネントの再レンダリングが起こりません。つまり、依存関係に含めても、Reactは変わった値にもとづいてエフェクトを再同期すべきことがわからないのです。また、ミュータブルなデータをレンダリング中(依存関係を演算するとき)に読み込むのは、Reactの原則に反します。レンダリングの純粋性が損われるからです。かわりに、外部のミュータブルな値はuseSyncExternalStoreでサブスクライブして読み込んでください(「外部ストアへのサブスクライブ」参照)。

refオブジェクトのcurrentプロパティもミュータブルです(「useRef」の「Reference」参照)。読み取った値は、依存関係に加えられません。useRefが返すrefオブジェクトそのものは依存関係になりえます。けれど、currentプロパティはあえてミュータブルなのです。そのため、再レンダリングを起こすことなく、値が追跡できます(「Referencing Values with Refs」参照)。逆に、値が変わっても再レンダリングはされません。リアクティブな値ではないので、変更されてもReactはエフェクトを再実行すべきことがわからないのです。

リンターはこれらの問題を、自動的に確かめます。

Reactがすべてのリアクティブな値を依存関係に含めたかどうか確める

リンターはReact向けに設定しておくと便利です。エフェクトのコードで用いられたすべてのリアクティブ値が、依存関係として宣言されているかを確かめてくれます。たとえば、前掲サンプル002のモジュールsrc/ChatRoom.tsxuseEffectに与える依存配列を以下のように空[]にしてみましょう。リンターが依存関係の不足をつぎのように警告するはずです。

React Hook useEffect has missing dependencies: 'roomId' and 'serverUrl'. Either include them or remove the dependency array.

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId, initUrl }) => {
	const [serverUrl, setServerUrl] = useState(initUrl);
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		return () => connection.disconnect();
	// }, [roomId, serverUrl]); // プロパティと状態は依存に含める
	}, []); // リンターが問題を警告する
	// ...
};

この警告はReactのエラーではありません。Reactによるコードのバグの指摘です。エフェクトで用いられているプロパティroomIdserverUrlは、どちらもあとで値が変わるかもしれません。けれど、依存関係がないので変更されてもエフェクトは再同期されないのです。つまり、ユーザーがUIでこれらの値を改めても、はじめのroomIdserverUrlに接続されたままになります。

リンターの警告にしたがって、依存をサンプル002のコード([roomId, serverUrl])に戻せば、接続のバグは解消されるでしょう。

[注記] Reactは、コンポーネント内で宣言されている値であっても、変更されないと知っている場合があります。たとえば、useStateが返す設定関数や、useRefから返されるrefオブジェクトは変動しません(「useRef」参照)。再レンダリングされても変更のないことが保証されているのです。変動しない値はリアクティブではないので、リンターは依存に含めることを求めません。ただ、依存に加えることはできます。値が変わらないので、依存による再同期は起きないというだけです。

再同期を避けるにはどうすべきか

前掲コード例では、プロパティ(roomId)と状態変数(serverUrl)を依存配列に加えて、リンターの警告が避けられました。

けれど、リンターに値がリアクティブではないこと、つまり再レンダリングしても変わらないことを「証明」してもよいのです。たとえば、前掲サンプル003では、serverUrlroomIdも、レンダリングに依存せず、値はつねに変わりませんでした。コンポーネントの外に宣言した変数は、依存関係には含めずに済むのです。

src/ChatRoom.tsx
const serverUrl = "https://localhost:1234"; // リアクティブではない
const roomId = "general"; // リアクティブではない
export const ChatRoom: FC = () => {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		return () => connection.disconnect();
	}, []); // ✅ 依存関係がない
	// ...
};

エフェクトの中に書いたとしても、レンダリング時に値が算出されるのでなければ、リアクティブではありません。

src/ChatRoom.tsx
export const ChatRoom: FC = () => {
	useEffect(() => {
		const serverUrl = "https://localhost:1234"; // リアクティブではない
		const roomId = "general"; // リアクティブではない
		const connection = createConnection(serverUrl, roomId);
		connection.connect();
		return () => connection.disconnect();
	}, []); // ✅ 依存関係がない
	// ...
};

エフェクトは、リアクティブなコードブロックです。コードブロックの中で読み取った値が変更されると、再同期されます。イベントハンドラは、インタラクションにつき1度しか呼び出されません。けれど、エフェクトは同期が必要になるたび実行されるのです。

依存関係は「選択」できません。依存配列には、エフェクトが読み込むすべてのリアクティブな値を含めなければならないのです(前述「コンポーネント本体に宣言された変数はすべてリアクティブ」参照)。これはリンターにより強制されます。場合によっては、無限ループやエフェクトの再同期が頻発するなどの問題につながるかもしれません。けれど、リンターの設定を制限することは避けましょう。そのようなときは、以下をお試しください。

  • エフェクトが独立した同期プロセスを表しているか確かめます。何も同期していないなら、エフェクトは不要かもしれません。独立したプロセスを複数同期している場合には分けてください(前述「各エフェクトは別個の同期処理を表す」参照)。
  • プロパティまたは状態を「反応」なしに読み取り、エフェクトは再同期させたくない場合には、ふたつに分けます(「Separating Events from Effects」参照)。
    • リアクティブな部分: エフェクトに残す。
    • リアクティブでない部分: イベント関数(ハンドラ)に分ける。
  • 依存関係にオブジェクトや関数を含めないでください。レンダリング時にオブジェクトまたは関数を生成し、エフェクトから読み取ると、レンダリングごとに異なるものと扱われます。すると、エフェクトの再同期が毎回起こってしまうのです(「Removing Effect Dependencies」参照)。

依存関係に対するリンターの警告

リンターはとても役立ちます。けれど、何でもできるわけではありません。リンターがわかるのは、異存関係の間違いだけです。その場合、どう対処するのがよいかまでは教えてくれません。リンターにいわれたとおり依存に加えたら、ループしてしまうという場合もありえます。だからといって、以下のようにリンターの設定で警告を止めるのは、できるだけ避けましょう。エフェクト内(あるいは外)のコードを改めて、警告は解消すべきです。つまり、値をリアクティブではなくして、依存関係から外します(「Removing Effect Dependencies」参照)。

useEffect(() => {
	// ...
	// 🔴 つぎのようなリンターの設定で警告を止めるのは避ける
	// eslint-ignore-next-line react-hooks/exhaustive-dependencies
}, []);

まとめ

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

  • コンポーネントは、マウント、更新、アンマウントされます。
  • エフェクトのライフサイクルは、それが書かれたコンポーネントとは別です。
  • エフェクトごとに記述された同期プロセスは別個で、それぞれ開始および停止します。
  • エフェクトを読み書きするときは、コンポーネントでなくエフェクトの観点から考えてください。
    • エフェクト: 同期をどう開始し停止するか。
    • コンポーネント: どうマウント、更新、アンマウントするか。
  • コンポーネント本体に宣言された値はリアクティブです。
  • リアクティブな値がエフェクトを再同期しなければならないのは、あとから変更される可能性があるからです。
  • リンターは、エフェクト内で用いたすべてのリアクティブ値が依存として定められていることを確かめます。
  • リンターがフラグづけしたエラーは正しく受け止め、ルールに反しないようコードを改めましょう。
5
3
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
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?