17
14

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 2022-10-11

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

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

イベントハンドラは、あらかじめ定めたインタラクションを行ったときにのみ再実行されます。それに対して、プロパティや状態変数のような読み取った値が、直前のレンダリング時と異なるとき再同期するのがエフェクトです。場合によっては、ふたつの動きを組み合わせたいかもしれません。エフェクトをある値に応じては再実行し、他の値には応答させないというときです。こういう処理の考え方についてご説明しましょう。

イベントハンドラかエフェクトかを選ぶ

まず、イベントハンドラとエフェクトがどう違うかのおさらいからです。

チャットルームコンポーネントを実装しているとします。 要件はつぎのふたつです。

  1. コンポーネントは、選択したチャットルームに自動的に接続します。
  2. [Send]ボタンをクリックすると、チャットにメッセージが送られます。

実装すべきコードは考えました。ただ、どこに置くのがよいでしょう。加えるのは、イベントハンドラかエフェクトかです。この疑問が生じたら、コードをなぜ実行しなければならないのか考えてください。

イベントハンドラは決められたインタラクションに応じて実行される

ユーザーの観点からは、メッセージが送信されるのは[Send]というボタンをクリックしたときであるべきです。他のタイミングや操作で送ったら、ユーザーは戸惑ってしまうでしょう。つまり、メッセージの送信はイベントハンドラでなければならないのです。イベントハンドラは、あらかじめ決められたインタラクションを処理します。

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId, serverUrl }) => {
	const [message, setMessage] = useState('');
	const handleSendClick = () => {
		sendMessage(message);
		setMessage('');
	};
	// ...
	return (
		<>
			{/* ... */}
			<input
				value={message}
				onChange={({ target: { value } }) => setMessage(value)}
			/>
			<button onClick={handleSendClick}>Send</button>
		</>
	);
};

イベントハンドラを用いたので、sendMessage(message)の実行は、ユーザーがボタンを押したときのみだとはっきりしました。

エフェクトは同期が必要になれば実行される

コンポーネントはチャットの間、部屋に接続したままにしなければなりません。その場合のコードはどこに書くかです。

このコードの実行は、決まったインタラクションにはもとづきません。ユーザーがなぜ、どのようにチャットルーム画面に遷移したかは問わないのです。画面を開いて見て、対話できるようになったら、コンポーネントは選択したチャットサーバーに接続したままでなければなりません。チャットルームコンポーネントがアプリケーションの初期画面で、ユーザーは何のインタラクションもしていなくても、接続は保つべきでしょう。そのようなときに使うのがエフェクトです。

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

このコードでは、選択されているチャットサーバーにつねに接続されています。ユーザーのインタラクションには関わりません。アプリケーションを開いただけだったり、別の部屋に移ったり、遷移した他の画面から戻ったのかもしれません。それでも、エフェクトはコンポーネントが今選んでいる部屋に同期し続け、必要に応じて再接続するのです(なお「React + TypeScript: React 18でコンポーネントのマウント時にuseEffectが2度実行されてしまう」参照)。このコード例は、つぎのサンプル001としてCodeSandboxに公開しました。

サンプル001■React + TypeScript: Separating Events from Effects 01

リアクティブな値とロジック

ごく単純化していえば、イベントハンドラとエフェクトの違いはつぎのとおりです。

  • イベントハンドラ: 「手動」で実行されます。
    • ボタンのクリックなど。
  • エフェクト: 「自動」で同期されます。
    • インタラクションにかかわらず必要に応じて。

コンポーネントの本体内で宣言されたプロパティや状態、変数は「リアクティブな値」と呼びます。つぎのコード例では、serverUrlはリアクティブな値でなくなりました。roomIdmessageはリアクティブ値で、レンダリングデータフローに加わります。

src/ChatRoom.tsx
const serverUrl = 'https://localhost:1234';
// export const ChatRoom: FC<Props> = ({ roomId, serverUrl }) => {
export const ChatRoom: FC<Props> = ({ roomId }) => {
	const [message, setMessage] = useState('');
	// ...
}

リアクティブな値というのは、再レンダリングにより変更されるかもしれません。たとえば、ユーザーはmessageを編集します。ドロップダウンで別のroomIdを選ぶこともあるでししょう。イベントハンドラとエフェクトは、値が変わったときの対応が異なります。

  • イベントハンドラの中に書かれたロジックはリアクティブではありません。再実行されるのは、ユーザーが同じインタラクション(クリックなど)を繰り返したときだけです。イベントハンドラはリアクティブな値を、その変化に「反応」することなく読み取れます。
  • エフェクトの中に書かれたロジックはリアクティブです。エフェクトが読み取るリアクティブな値は、依存配列に加えなければなりません。再レンダリング時にその値が変わっていたら、Reactは新たな値でエフェクトのロジックを再実行するのです。

なお、リアクティブな値とロジックは分けて捉えてください。コンポーネントの本体内で宣言された「リアクティブな値」の処理であっても、「リアクティブでないロジック」に置いた方がよい場合もあるからです。先のコード例を改めて見てみましょう。

イベントハンドラ内のロジックはリアクティブではない

つぎのコードをご覧ください。このロジックはリアクティブでしょうか。

// ...
sendMessage(message);
// ...

ユーザーから見て、messageの書き替えは、値を送信したいのではありません。あくまで、ユーザーが入力していることを意味するだけです。つまり、メッセージを送るロジックはリアクティブであってはなりません。「リアクティブな値」が変わったからといって、再実行してはいけないのです。そのため、このロジックはイベントハンドラの中に置きました。

const handleSendClick = () => {
	sendMessage(message);
	// ...
};

イベントハンドラはリアクティブではありません。したがって、sendMessage(message)のロジックが実行されるのは、ユーザーが[Send]ボタンをクリックしたときだけです。

エフェクト内のロジックはリアクティブである

つぎのコードを考えましょう。

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

ユーザーから見れば、roomIdを変えるのは、別の部屋に接続したいということです。したがって、部屋に接続するためのロジックは、リアクティブでなければなりません。これらのコードは「リアクティブな値」に「対応」し、値が異なるときは再実行すべきです。そのため、このロジックはエフェクトの中に置きます。

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

エフェクトはリアクティブです。したがって、コードcreateConnection(serverUrl, roomId)およびconnection.connect()は依存関係([roomId, serverUrl])の値が異なるごとに実行されます。エフェクトは、チャット接続を現在選ばれている部屋に同期するのです。

なお、前掲サンプル001serverUrlは、コンポーネントAppの外に宣言されているのでリアクティブではありません。けれど、ChatRoomにプロパティとして渡されています。そのため、子コンポーネントにとってはリアクティブ値なのです。

リアクティブでないロジックをエフェクトから切り出す

厄介になるのは、リアクティブなロジックとリアクティブでないロジックを混在させたときです。

たとえば、ユーザーがチャットに接続したとき、通知を示すとしましょう。通知背景色の現在のテーマはプロパティから読み込み、ダークまたはライトの二択です。通知を正しいカラーで表示します。

export const ChatRoom: FC<Props> = ({ theme, roomId, serverUrl }) => {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.on('connected', () => {
			showNotification('Connected!', theme);
		});
		connection.connect();
		// ...
	}, [roomId, serverUrl]);

};

ただし、themeはリアクティブな値です(再レンダリングにより変更されるかもしれません)。そして、エフェクトが読み取るすべてのリアクティブ値は、依存関係の宣言に含めます(「Reactがすべてのリアクティブな値を依存関係に含めたかどうか確める」参照)。したがって、themeは依存関係に加えなければならないのです。

export const ChatRoom: FC<Props> = ({ theme, roomId, serverUrl }) => {
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.on('connected', () => {
			showNotification('Connected!', theme);
		});
		connection.connect();
		return () => connection.disconnect();
	}, [theme, roomId, serverUrl]); // ✅ OK: すべての依存関係を宣言
	// ...
};

このコードの動きが、つぎのサンプル002で実際に確かめられます。ユーザー体験にどのような問題が生じるか試してみてください。

サンプル002■React + TypeScript: Separating Events from Effects 02

roomIdが変わると、チャットは再接続されます。この動きは期待したとおりです。けれど、themeも依存関係に含めました。そのため、ダークとライトのテーマを切り替えただけでも、チャットはそのたびに再接続されてしまいます。これは問題です。

つまり、このコードはエフェクト(リアクティブなロジック)の中にあっても、リアクティブに実行したくありません。

// ...
showNotification('Connected!', theme);
// ...

このリアクティブでないロジックを、リアクティブなエフェクトと切り分ける方法が必要です。

エフェクトイベントを宣言する

[注記] この項でご説明するAPI(useEffectEvent)は実験的です。Reactの正規リリースでは、まだ使うことができません。

useEffectEventは、リアクティブでないロジックをエフェクトから切り出す特別なフックです。

import { useEffect, useEffectEvent } from 'react';

export const ChatRoom: FC<Props> = ({ theme, roomId, serverUrl }) => {
	const onConnected = useEffectEvent(() => {
		showNotification('Connected!', theme);
	});
	// ...
};

このコードで、onConnectedをエフェクトイベントと呼びます。エフェクトのロジックの一部であっても、イベントハンドラのように振る舞うのです。エフェクトイベント内のロジックは、リアクティブできありません。プロパティや状態については、つねに直近の値を「見ます」。

こうして、onConnectedエフェクトイベントをエフェクトの中から呼び出すのです。

export const ChatRoom: FC<Props> = ({ theme, roomId, serverUrl }) => {
	const onConnected = useEffectEvent(() => {
		showNotification('Connected!', theme);
	});
	useEffect(() => {
		const connection = createConnection(serverUrl, roomId);
		connection.on('connected', () => {
			onConnected();
		});
		connection.connect();
		return () => connection.disconnect();
	}, [roomId, serverUrl]); // ✅ OK: すべての依存関係を宣言
	// ...
};

これで問題は解決です。onConnectedはエフェクトの依存配列からは除いてください。イベントエフェクトはリアクティブではありません。したがって、依存に含めてはならないのです

改められたコードの動作が期待どおりであることを、つぎのサンプル003でお確かめください。

サンプル003■React + TypeScript: Separating Events from Effects 03

エフェクトイベントは、イベントハンドラととてもよく似ています。おもな違いは、イベントハンドラがユーザーインタラクションに応じて実行されるのに対して、エフェクトイベントはエフェクトから呼び出されることです。エフェクトイベントを使えば、エフェクトのリアクティブなロジックと、リアクティブにすべきでないコードとの間の「連鎖が断ち切れます」。

最新のプロパティと状態をエフェクトイベントで読み取る

[注記] この項でご説明するAPIは実験的です。Reactの正規リリースでは、まだ使うことができません。

依存関係のリンターを抑制したくなるときもあるでしょう。エフェクトイベントを用いれば、そうした多くの場合が修正できます。

たとえば、ページの訪問をログにとるエフェクトがあるとしましょう。

export const Page: FC = () => {
	useEffect(() => {
		logVisit();
	}, []);
	// ...
};

あとになって、サイトに複数のルートを加えました。そこで、Pageコンポーネントの受け取るプロパティに与えたのが、現在のパスをもつurlです。logVisit呼び出しの引数にこのurlを渡そうとすると、依存関係のリンターが警告を発します。

React Hook useEffect has a missing dependency: 'url'

ここで、コードに何をさせたいか考えなければなりません。異なるURLへの訪問は、別個にログを残したいでしょう。各URLが異なるページを表すからです。つまり、logVisitの呼び出しは、urlに対してリアクティブであることが求められます。そのため、リンターにしたがって、urlを依存関係に加えるべきです。

export const Page: FC<Props> = ({ url }) => {
	useEffect(() => {
		logVisit(url);
		// }, []); // 🔴 NG: 依存関係にurlが含まれていない
	}, [url]); // ✅ OK: すべての依存関係を宣言
	// ...
};

さらに、ショッピングカート内の商品数を、ページ訪問のログに加えることになったとしましょう。logVisitの引数にnumberOfItemsを加えれば、また依存関係のリンターに注意されます。

React Hook useEffect has a missing dependency: 'numberOfItems'

export const Page: FC<Props> = ({ url }) => {
	const { items } = useContext(ShoppingCartContext);
	const numberOfItems = items.length;
	useEffect(() => {
		// logVisit(url);
		logVisit(url, numberOfItems);
	}, [url]); // 🔴 NG: 依存関係にnumberOfItemsが含まれていない
	// ...
};

エフェクトの中でnumberOfItemsが使われたので、リンターは値を依存関係に含めるよう求めたのです。けれど、logVisitの呼び出しが、numberOfItemsに対してリアクティブであることは望ましくありません。ユーザーがショッピングカートに何か入れれば、numberOfItemsの値は変わります。けれど、ユーザーがページを再訪したことにはならないからです。つまり、ページへの訪問は、むしろイベントに似ています。ページを訪問するのは、特定のときだからです。

そのためには、useEffectEventを用いてロジックはふたつに分けてください。

export const Page: FC<Props> = ({ url }) => {
	const { items } = useContext(ShoppingCartContext);
	const numberOfItems = items.length;
	const onVisit = useEffectEvent((visitedUrl: string) => {
		logVisit(visitedUrl, numberOfItems);
	});
	useEffect(() => {
		onVisit(url);
	}, [url]); // ✅ OK: すべての依存関係を宣言
	// ...
};

このコードのonVisitはエフェクトイベントです。中に記述したコードはリアクティブではありまません。ですから、コード内で用いたnumberOfItems(あるいは他のリアクティブな値)が変更されても、エフェクト内のロジックは再実行されずに済むのです。

ただし、エフェクトはリアクティブのままです。エフェクト内のコードはurlプロパティを用いるので、異なるurlで再レンダリングされるたびに再実行されます。そのとき、エフェクトイベントonVisitも呼び出されるのです。

その結果、urlが変わるたびlogVisitは呼び出され、つねに最新のnumberOfItemsを読み取ります。けれど、numberOfItems自体が変更されたからといって、エフェクトイベントのコードは再実行されません。

エフェクトイベントに渡す引数

onVisit()は引数なしに呼び出して、エフェクトイベントの中からurlを読み取ればよいのではないかと思ったかもしれません。

const onVisit = useEffectEvent(() => {
	logVisit(url, numberOfItems);
});
useEffect(() => {
	onVisit();
}, [url]);

これでも動きます。けれど、urlを明示的にエフェクトイベントに渡す方が適切です。urlを関数の引数にすることは、異なるurlのページに遷移するのがユーザーから見て別の「イベント」として構成されると示すことになりますvisitedUrlは発生した「イベント」の一部なのです。

const onVisit = useEffectEvent((visitedUrl: string) => {
	logVisit(visitedUrl, numberOfItems);
});
useEffect(() => {
	onVisit(url);
}, [url]);

エフェクトイベント(onVisit)は、引数としてvisitedUrlを明示的に「要求」します。そのため、エフェクトの依存関係から、うっかりurlを省くことはなくなりました。依存関係からurlを外せば(別のページに遷移しても値の変更は認識されなくなるので)、リンターが警告を発するからです。onVisiturlに関してリアクティブでなければなりません。したがって、関数内から直にurlを読み取る(リアクティブでなくす)のでなく、値はあくまでエフェクトから渡すのです。

これは、エフェクトの中に非同期のロジックが含まれる場合にはとくに重要になります。

const onVisit = useEffectEvent((visitedUrl: string) => {
	logVisit(visitedUrl, numberOfItems);
});
useEffect(() => {
	setTimeout(() => {
		onVisit(url);
	}, 5000); // 訪問のログを遅らせる
}, [url]);

このコード例で、onVisitの中のurlが対応するのは、直近のプロパティ値です(すでに変更されたかもしれません)。けれど、引数に渡されるvisitedUrlは、そのエフェクト(およびonVisit呼び出しの非同期処理)が実行されたときのurlの値になります。

依存リンターのルールを制限してしまってもよいか

既存のコードベースでは、つぎのようにリントルールが制限されている場合を見かけるかもしれません。

export const Page: FC<Props> = ({ url }) => {
	const { items } = useContext(ShoppingCartContext);
	const numberOfItems = items.length;
	useEffect(() => {
		logVisit(url, numberOfItems);
		// 🔴 NG: つぎのようなリンターの制限は避ける
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [url]);
	// ...
};

useEffectEventがReactの安定した機能として組み込まれたら、このようなリンターの制限は避けましょう

ルールの制限による最大の問題は、Reactがそのエフェクトの依存関係についてもはや警告しなくなることです。コードに書き加えた新しいリアクティブな依存関係にエフェクトが「反応」しなければならなくても、Reactは何も告げません。たとえば、前のコード例では、依存関係にurlを含めました。Reactがそう促したからです。リンターを無効にしたエフェクトは、あとで編集しても警告が受け取れません。これがバグを生むのです。

つぎのコードは、リンターを制限して起きたバグに陥った例です。handleMove関数は、読み取った状態変数canMoveの現在のブール値により、Dotコンポーネントがカーソルを追いかけるかどうか決めています。けれど、handleMoveの本体では、canMoveの値はつねにtrueのままです。

export default function App() {
	const [position, setPosition] = useState({ x: 0, y: 0 });
	const [canMove, setCanMove] = useState(true);
	const handleMove = ({ clientX, clientY }: PointerEvent) => {
		if (canMove) {
			setPosition({ x: clientX, y: clientY });
		}
	};
	useEffect(() => {
		window.addEventListener('pointermove', handleMove);
		return () => window.removeEventListener('pointermove', handleMove);
		// 🔴 NG: リンターに制限を加える
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, []);
	return (
		<div className="App">
			{/* ... */}
			<Dot position={position} />
		</div>
	);
}

問題は、依存関係のリンターを制限したことにあります。制限を外せば、エフェクトが、handleMove関数に依存すると知らされるでしょう。handleMoveはコンポーネント本体内で宣言されているため、リアクティブな値です。すべてのリアクティブな値は、依存関係に含めなければなりません。そうしないと、値が変わっても、エフェクトの処理は古い値のまま更新されなくなってしまうからです。

このコード例は、リンターに加えた制限により、エフェクトにリアクティブな依存関係は一切ない([])とReactを「欺きました」。そのため、canMove(およびhandleMove)が変更されても、Reactはエフェクトを再同期しなかったのです。Reactがエフェクトを再同期しませんので、イベント(pointermove)に加えられたリスナーは、はじめてのレンダリング時につくられたhandleMove関数のまま変わりません。そのときのcanMoveの値はtrueです。結果として、いくらcanMoveの値を切り替えても、handleMoveははじめの値trueを見続けてしまいました。

リンターを制限しなければ、値が古いままという問題は生じませんuseEffectEventを用いれば、リンターを「欺く」必要はなくなります(サンプル004)。エフェクトの依存配列は空([])のままで済むのです。

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

	// const handleMove = ({ clientX, clientY }: PointerEvent) => {
	const onMove = useEffectEvent(({ clientX, clientY }: PointerEvent) => {
		if (canMove) {
			setPosition({ x: clientX, y: clientY });
		}
		// };
	});
	useEffect(() => {
		// window.addEventListener("pointermove", handleMove);
		window.addEventListener("pointermove", onMove);
		// return () => window.removeEventListener("pointermove", handleMove);
		return () => window.removeEventListener("pointermove", onMove);
	}, []); // ✅ OK: 依存関係はなし

}

サンプル004■React + TypeScript: Separating Events from Effects 04

useEffectEventを用いれば、リンターを「欺く」必要はなくなり、コードが期待どおりに動きます。

useEffectEventがつねに正しい解決を導くとはかぎりません。エフェクトイベントに切り出すのは、リアクティブにしたくないコードだけです。たとえば、前掲サンプル004は、エフェクトのコードがcanMoveに関してリアクティブにならないよう考えました。そこで、エフェクトイベントに切り分けたのです。

リンターの制限はせずに、エフェクトの依存関係が正しく定められる他のやり方ついては、「React + TypeScript: エフェクトの依存を除く」をご参照ください。

エフェクトイベントの制約

[注記] この項でご説明するAPIは実験的です。Reactの正規リリースでは、まだ使うことができません。

今のところ、エフェクトイベントは使い方がかなりかぎられています。

  • エフェクト内からのみ呼び出してください
  • 他のコンポーネントやフックに渡してはいけません

たとえば、宣言したエフェクトイベント(onTick)を、つぎのように他のフック(useTimer)に渡さないでください。

src/Timer.tsx
export const Timer: FC = () => {
	const [count, setCount] = useState(0);
	const onTick = useEffectEvent(() => {
		setCount(count + 1);
	});
	useTimer(onTick, 1000); // 🔴 NG: エフェクトイベントを外に渡す
	return <h1>{count}</h1>;
};
src/useTimer.ts
export const useTimer = (callback: () => void, delay: number) => {
	useEffect(() => {
		const id = setInterval(() => {
			callback();
		}, delay);
		return () => {
			clearInterval(id);
		};
	}, [delay, callback]); // 依存関係にcallbackを含めなければならない
};

エフェクトイベントはエフェクトのコードの中のリアクティブでない部分と捉えられます。エフェクトイベントは必ずエフェクトと同じ場所で宣言し、エフェクトの中から呼び出しましょう。各モジュールの具体的なコードと動きについては、以下のサンプル005でお確かめください。

src/Timer.tsx
export const Timer: FC = () => {
	const [count, setCount] = useState(0);
	/* const onTick = useEffectEvent(() => {
		setCount(count + 1);
	}); */
	// useTimer(onTick, 1000);
	useTimer(() => {
		setCount(count + 1);
	}, 1000);
	return <h1>{count}</h1>;
};
src/useTimer.ts
export const useTimer = (callback: () => void, delay: number) => {
	const onTick = useEffectEvent(() => {
		callback();
	});
	useEffect(() => {
		const id = setInterval(() => {
			// callback();
			onTick(); // ✅ OK: エフェクト内から内部的に呼び出す
		}, delay);
		return () => {
			clearInterval(id);
		};
		// }, [delay, callback]);
	}, [delay]); // onTick(エフェクトイベント)は依存関係に含めない
};

サンプル005■React + TypeScript: Separating Events from Effects 05

まとめ

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

  • イベントハンドラは、決まったインタラクションに応じて実行されます。
  • エフェクトは、同期が必要になればいつでも実行されます。
  • イベントハンドラ内のロジックはリアクティブではありません。
  • エフェクト内のロジックはリアクティブです。
  • イベント内のリアクティブでないロジックは、エフェクトイベントに切り出せます。
  • エフェクトイベントは、エフェクト内からのみ呼び出します。
  • エフェクトイベントは、他のコンポーネントやフックに渡してはいけません。
17
14
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
17
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?