LoginSignup
0
1

React + TypeScript: カスタムフックでロジックを再利用する

Last updated at Posted at 2023-08-16

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

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

Reactには、useStateuseContextuseEffectなどさまざまな組み込みフックが備わっています。けれど、もっと特定の目的に応じたフックが必要になることもあるでしょう。たとえば、つぎのような用途です。

  • データの取得。
  • ユーザがオンラインかどうかの監視。
  • チャットルームへの接続。

Reactの組み込みフックには見当たらないかもしれません。けれど、アプリケーションの必要に合わせて、フックは自作できます。

カスタムフック:コンポーネント間でロジックを共有する

ネットワークに大きく依存するアプリケーションを開発しているとします(ほとんどのアプリケーションがそうでしょう)。アプリケーションの使用中にネットワークが急に切れたら、ユーザーに警告を示したいです。コンポーネントにはつぎのふたつの機能が必要になると考えられます。

  • ネットワークがオンラインかどうかを保持する状態。
  • グローバルのonline(接続)とoffline(切断)イベントにリスナーを登録するエフェクト。
    • リスナー関数が状態を更新。

こうして、コンポーネントとネットワークを同期させようということです。まずは、カスタムフックは使わず、ネット接続が想定された簡単なアプリケーションをふたつつくりましょう。

  • isOnline: ネットワークがオンラインかどうかの状態変数。
  • useEffect: onlineofflineイベントのリスナーを登録。

ひとつめは、ネットワークに接続している('Online')か、切断している('Disconnected')かを画面に表示します(サンプル001)。実際に、ネットにつないだり、切ったりして結果をお確かめください(インタラクションはありません)。

src/StatusBar.tsx
export const StatusBar: FC = () => {
	const [isOnline, setIsOnline] = useState(true);
	useEffect(() => {
		const handleOnline = () => {
			setIsOnline(true);
		};
		const handleOffline = () => {
			setIsOnline(false);
		};
		window.addEventListener('online', handleOnline);
		window.addEventListener('offline', handleOffline);
		return () => {
			window.removeEventListener('online', handleOnline);
			window.removeEventListener('offline', handleOffline);
		};
	}, []);
	return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
};

サンプル001■React + TypeScript: Reusing Logic with Custom Hooks 01

ふたつめのアプリケーションも、ネットワーク接続に使うロジックは同じです。画面に表示するのはボタンで、ネットワークにつながっているか切れているかで見た目と振る舞いが変わります。

  • ネットワークに接続中: 'Save progress'と表示。
    • クリックするとコンソールに'Progress saved'と出力。
  • ネットワークと切断中: 'Reconnecting...'と表示。
    • ボタンは無効。
src/SaveButton.tsx
export const SaveButton: FC = () => {
	const [isOnline, setIsOnline] = useState(true);
	useEffect(() => {
		const handleOnline = () => {
			setIsOnline(true);
		};
		const handleOffline = () => {
			setIsOnline(false);
		};
		window.addEventListener('online', handleOnline);
		window.addEventListener('offline', handleOffline);
		return () => {
			window.removeEventListener('online', handleOnline);
			window.removeEventListener('offline', handleOffline);
		};
	}, []);
	const handleSaveClick = () => {
		console.log('✅ Progress saved');
	};
	return (
		<button disabled={!isOnline} onClick={handleSaveClick}>
			{isOnline ? 'Save progress' : 'Reconnecting...'}
		</button>
	);
};

ネットワーク接続のロジックは、前掲StatusBarコンポーネントからそのままコピー&ペーストしました。動きは、つぎのサンプル002でお確かめください。

サンプル002■React + TypeScript: Reusing Logic with Custom Hooks 02

前掲ふたつのアプリケーションは見た目が異なるものの、ネットワーク接続のロジックはまったく同じです。別モジュールに切り出せれば、使い回しができるでしょう。

共通のロジックを自作のカスタムフックとして切り出す

前掲ふたつのアプリケーションに共通する状態(isOnline)とエフェクト(useEffect)のロジックを備えたカスタムフックuseOnlineStatusができたとします。そうして書き替えた、ふたつのコンポーネントの定めがつぎのコードです。

src/StatusBar.tsx
export const StatusBar: FC = () => {
	const isOnline = useOnlineStatus();
	return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
};
src/SaveButton.tsx
export const SaveButton: FC = () => {
	const isOnline = useOnlineStatus();
	const handleSaveClick = () => {
		console.log('✅ Progress saved');
	};
	return (
		<button disabled={!isOnline} onClick={handleSaveClick}>
			{isOnline ? 'Save progress' : 'Reconnecting...'}
		</button>
	);
};

フックの戻り値は状態変数(isOnline)としました。本体のロジックは、もとのコンポーネントからそのまま移してしまって構いません。フックのモジュールsrc/useOnlineStatus.tsの実装はつぎのとおりです。

src/useOnlineStatus.ts
import { useState, useEffect } from 'react';

export const useOnlineStatus = () => {
	const [isOnline, setIsOnline] = useState(true);
	useEffect(() => {
		const handleOnline = () => {
			setIsOnline(true);
		};
		const handleOffline = () => {
			setIsOnline(false);
		};
		window.addEventListener('online', handleOnline);
		window.addEventListener('offline', handleOffline);
		return () => {
			window.removeEventListener('online', handleOnline);
			window.removeEventListener('offline', handleOffline);
		};
	}, []);
	return isOnline;
};

ふたつのコンポーネントはルートモジュールsrc/App.tsxでまとめましょう。はじめのコード例と同じく、ネットワークにつないだり、切ったりすると、それぞれの画面上の表示が変わります。以下のサンプル003で動きをお確かめください。

src/App.tsx
import { SaveButton } from './SaveButton';
import { StatusBar } from './StatusBar';

export default function App() {
	return (
		<>
			<SaveButton />
			<StatusBar />
		</>
	);
}

サンプル003■React + TypeScript: Reusing Logic with Custom Hooks 03

カスタムフックにより、コンポーネント間のロジックの重複が減らせました。もっと大事なのは、コンポーネント内のコードが読みやすくなったことですフックがどのように実装(ブラウザへのイベント登録)されているか、コンポーネントからは問いませんフックの名前(オンラインステータスを使う)と戻り値から、何をするのかがわかるのです

カスタムフックに切り出すことで、細々とした処理は隠蔽できます。外部システムやブラウザ API とのやり取りなどは、フックの中で済ませればよいことです。すると、コンポーネントのコードには、実装でなく意図が示されるようになるでしょう。

フックの名前はつねにuseで始める

Reactアプリケーションは、コンポーネントで組み立てられます。そして、コンポーネントを構築するのがフックです。フックには組み込みやカスタムがあります。他の人がつくったカスタムフックを使うことも少なくないでしょう。さらに、自作することも考えられます。

コンポーネントやフックを書く場合、名前づけはつぎの規則にしたがってください。

  1. Reactコンポーネントの名前は大文字で始めます。たとえば、前掲コード例のStatusBarSaveButtonです。また、コンポーネントの戻り値は、JSXのようにReactが表示の仕方を知っている値でなければなりません。
  2. フックはuseで始めて頭文字が大文字の名前を続けてください。たとえば、useState(組み込み)やuseOnlineStatus(カスタム)です。フックからは任意の値が返せます。

この規則にしたがうことにより、コンポーネントを見るだけで、状態やエフェクト、その他のReactの機能がどこに「隠されて」いるのか、つねに明確に把握できるようになるでしょう。たとえば、コンポーネントから呼び出す関数がgetColor()だとしたら、名前の頭にuseはつかないので、フックではありません。つまり、関数はReactの状態をもたないということです。けれど、呼び出す関数の名前がuseOnlineStatus()であればフックを意味します。状態を備えるなど、他のフックの呼び出しも含まれていそうです。

[注記] 前述の規則に反した名前づけは、リンターがReactの設定になっていると警告されます。たとえば、前掲サンプル003のカスタムフックuseOnlineStatusの名前をgetOnlineStatusに書き替えてみましょう。示されるのはつぎの警告です。

React Hook "useState" is called in function "getOnlineStatus" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter.

React Hook "useEffect" is called in function "getOnlineStatus" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter.

  1. useで始まらない関数getOnlineStatusからはフック(useStateuseEffect)が呼び出せません。
    • フックが呼び出せるのは、React関数コンポーネントかカスタムフックだけです。
  2. 関数がカスタムフックでなくコンポーネントであれば、名前は大文字で始めなければなりません。

レンダリング中に呼び出される関数の名前はすべてuseで始める方がよいか

フックを呼び出さない関数は、通常の関数です。名前をuseで始めることは避けてください。

// 🔴 NG: フックを使わない関数にフックの名前づけ
// function useSorted(items: Item[]) {
// ✅ OK: フックを使わない通常の関数
function getSorted(items: Item[]) {
	return items.slice().sort();
}

通常の関数であれば、条件文の中など、どこからでも呼び出せます。

function List({ items: Item[], shouldSort: boolean }) {
	let displayedItems = items;
	if (shouldSort) {
		// ✅ OK: フックではないので条件文中からでも呼び出せる
		displayedItems = getSorted(items);
	}
	// ...
}

内部でフックを用いる関数は、フックですので名前の頭にuseをつけるべきです。

// ✅ OK: 他のフックを用いるのでフックとする
function useAuth() {
	return useContext(Auth);
}

もっとも、フックは内部からフックを呼ばなければならないという技術的な制約はReactにありません。仕様のうえでは、他のフックを呼び出さないフックも定められます。けれど、混乱や制約のもととなるので、避けた方がよいでしょう。ただし、まれに役立つことはあるかもしれません。たとえば、関数が今現在はフックを使っていないとしましょう。でも将来、フックの呼び出しを加える可能性があるという場合です。それを示すために名前をuseで始めるのは意味があります。

// ✅ OK: あとで他のフックを使うつもりなのでフックとして定める
function useAuth() {
	// TODO: 認証の実装ができたらダミーの行と差し替える
	// return useContext(Auth);
	return TEST_USER;
}

こうすると、コンポーネントは関数を条件文内から呼び出せません。これは、関数内に実際にフックの呼び出しを加え、フックとして使うようになったときに重要です。将来にわたって、フックとして用いることがない場合には、名前の先頭にuseを加える(フックとして定める)ことは避けてください。

カスタムフックが共有するのはロジックであって状態ではない

カスタムフックが切り出された前掲サンプル003で、ネットワークをつないだり切ったりすると、ふたつのコンポーネントはともに更新されました。けれど、ひとつの同じ状態変数isOnlineが、ふたつの間で共有されたと捉えるのは誤りです。

src/StatusBar.tsx
export const StatusBar: FC = () => {
	const isOnline = useOnlineStatus();

};
src/SaveButton.tsx
export const SaveButton: FC = () => {
	const isOnline = useOnlineStatus();

};

動作はカスタムフックを用いる前のコンポーネント(サンプル001および002)と変わるところはありません。

src/StatusBar.tsx
export const StatusBar: FC = () => {
	const [isOnline, setIsOnline] = useState(true);
	useEffect(() => {

	}, []);
};
src/SaveButton.tsx
export const SaveButton: FC = () => {
	const [isOnline, setIsOnline] = useState(true);
	useEffect(() => {

	}, []);

};

同じカスタムフックを使っても、コンポーネントの状態変数とエフェクトは、それぞれ別個だからです。ふたつのもっている値が一致するのは、(ネットワークの接続/切断という)同じ外部の値に同期しているからにすぎません。

別のFormコンポーネントの例で考えてみましょう(サンプル004)。以下のコードから、共通に切り出せるロジックはつぎのとおりです。

  1. 状態変数。
    • firstNamelastName
  2. onChangeイベントハンドラ。
    • handleFirstNameChangehandleLastNameChange関数。
  3. 戻り値のJSXが含む<input />要素に加えるプロパティ。
    • valueonChange
export default function Form() {
	const [firstName, setFirstName] = useState('Mary');
	const [lastName, setLastName] = useState('Poppins');
	const handleFirstNameChange: ChangeEventHandler<HTMLInputElement> = ({
		target: { value }
	}) => {
		setFirstName(value);
	};
	const handleLastNameChange: ChangeEventHandler<HTMLInputElement> = ({
		target: { value }
	}) => {
		setLastName(value);
	};
	return (
		<>
			<label>
				First name:
				<input value={firstName} onChange={handleFirstNameChange} />
			</label>
			<label>
				Last name:
				<input value={lastName} onChange={handleLastNameChange} />
			</label>

		</>
	);
}

サンプル004■React + TypeScript: Reusing Logic with Custom Hooks 04

共通のロジックを切り出したのが、つぎのカスタムフックモジュールsrc/useFormInput.tsです。

src/useFormInput.ts
import { useState } from 'react';
import type { ChangeEventHandler } from 'react';

export const useFormInput = (initialValue: string) => {
	const [value, setValue] = useState(initialValue);
	const handleChange: ChangeEventHandler<HTMLInputElement> = ({
		target: { value }
	}) => {
		setValue(value);
	};
	const inputProps = {
		value,
		onChange: handleChange
	};
	return inputProps;
};

すると、FormコンポーネントはカスタムフックuseFormInputを用いてつぎのように書き替えられます(サンプル005)。2度呼び出すフックに渡す状態変数の初期値が異なることにご注目ください。これは、状態変数(value)もそれを扱うイベントハンドラ関数(handleChange)も、フックの呼び出しごとに異なるということです。

src/App.tsx
export default function Form() {
	// const [firstName, setFirstName] = useState('Mary');
	// const [lastName, setLastName] = useState('Poppins');
	const firstNameProps = useFormInput('Mary');
	const lastNameProps = useFormInput('Poppins');
	/* const handleFirstNameChange: ChangeEventHandler<HTMLInputElement> = ({
		target: { value }
	}) => {
		setFirstName(value);
	};
	const handleLastNameChange: ChangeEventHandler<HTMLInputElement> = ({
		target: { value }
	}) => {
		setLastName(value);
	}; */
	return (
		<>
			<label>
				First name:
				{/* <input value={firstName} onChange={handleFirstNameChange} /> */}
				<input {...firstNameProps} />
			</label>
			<label>
				Last name:
				{/* <input value={lastName} onChange={handleLastNameChange} /> */}
				<input {...lastNameProps} />
			</label>
			<p>
				<b>
					{/* Good morning, {firstName} {lastName}. */}
					Good morning, {firstNameProps.value} {lastNameProps.value}.
				</b>
			</p>
		</>
	);
}

サンプル005■React + TypeScript: Reusing Logic with Custom Hooks 05

カスタムフックは、状態が備わったロジックを切り出して共有します共通の状態を保持するのではありません状態はフックの呼び出しごとに別個です。そのため、サンプル005では、カスタムフックuseFormInputの同じロジックを用いて、ふたつの異なる状態が扱えました。

状態を共有したいという場合には「React + TypeScript: コンポーネント間で状態を共有する」をご参照ください。

フックの間でリアクティブな値を渡す

カスタムフックの中のコードは、コンポーネントが再レンダーされるごとに再実行されます。したがって、コンポーネントと同じく、カスタムフックは純粋な関数でなければなりません。カスタムフックのコードは、それを用いるコンポーネント本体の一部だと考えてください。

カスタムフックは、コンポーネントとともに再レンダーされます。そのため、つねに最新のプロパティと状態が受け取れるのです。つぎのChatRoomコンポーネントで、サーバーURL(serverUrl)やチャットルーム(roomId)を変更してみてください(サンプル006)。

src/ChatRoom.tsx
export const ChatRoom: FC<Props> = ({ roomId }) => {
	const [serverUrl, setServerUrl] = useState('https://localhost:1234');
	useEffect(() => {
		const options = {
			serverUrl,
			roomId
		};
		const connection = createConnection(options);
		connection.on('message', (connectionInfo, theme) => {
			showNotification('New connection: ' + connectionInfo, theme);
		});
		connection.connect();
		return () => connection.disconnect();
	}, [roomId, serverUrl]);

};
src/chat.ts
export const createConnection = ({ serverUrl, roomId }: Props) => {
	// 実際にはサーバーとの接続を実装
	let messageCallback: MessageCallback | null;
	return {
		connect() {
			const connectionInfo = `✅ Connecting to "${roomId}" room at ${serverUrl}...`;
			console.log(connectionInfo);
			messageCallback && messageCallback(connectionInfo, 'white');
		},
		disconnect() {
			const connectionInfo = `❌ Disconnected from "${roomId}" room at ${serverUrl}`;
			console.log(connectionInfo);
			messageCallback && messageCallback(connectionInfo, 'dark');
			messageCallback = null;
		},
		on(event: string, callback: MessageCallback) {

			messageCallback = callback;
		}
	};
};

サンプル006■React + TypeScript: Reusing Logic with Custom Hooks 06

serverUrlroomIdの値が変わると、エフェクトは変更に「反応」して再同期されます。コンソールの出力から、チャットはエフェクトの依存配列が改められるたびに再接続していることを確かめられるでしょう。

つぎに、ChatRoomコンポーネントから、エフェクトのコードを新たなカスタムフック(useChatRoom)に移します。

src/useChatRoom.ts
import { useEffect } from 'react';
import { createConnection } from './chat';
import { showNotification } from './notifications';

type Props = { serverUrl: string; roomId: string };
export const useChatRoom = ({ serverUrl, roomId }: Props) => {
	useEffect(() => {
		const options = { serverUrl, roomId };
		const connection = createConnection(options);
		connection.on('message', (connectionInfo, theme) => {
			showNotification('New connection: ' + connectionInfo, theme);
		});
		connection.connect();
		return () => connection.disconnect();
	}, [roomId, serverUrl]);
};

これで、ChatRoomコンポーネントにはエフェクトが要らなくなりました。カスタムフックuseChatRoomを呼び出せば済み、実装について気にする必要はありません。そして、コンポーネントからロジックが切り分けられて、見やすいコードになったでしょう。もちろん、プロパティや状態の変更にロジックが反応することは変わりません(サンプル007)。

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

	/* useEffect(() => {

	}, [roomId, serverUrl]); */
	useChatRoom({ roomId, serverUrl });

};

サンプル007■React + TypeScript: Reusing Logic with Custom Hooks 07

このコード例では、ChatRoomコンポーネントがひとつのフック(useState)から得た状態変数(serverUrl)をプロパティ(roomId)と合わせて別のフック(useChatRoom)に渡しました。これらは、ChatRoomコンポーネントが再レンダーされるたびにフックを更新するリアクティブな値です。再レンダー後に受け取った値が異なれば、エフェクトはチャットに再接続します。

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

	useChatRoom({ roomId, serverUrl });

};

カスタムフックにイベントハンドラを渡す

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

カスタムフックuseChatRoomの使い回しが増えてくると、コンポーネントから動きをカスタマイズしたくなることもあるでしょう。たとえば、今メッセージが届いたらどうするかは、フックの中に決め打ちです。

src/useChatRoom.ts
export const useChatRoom = ({ serverUrl, roomId }: Props) => {
	useEffect(() => {

		connection.on('message', (connectionInfo, theme) => {
			showNotification('New connection: ' + connectionInfo, theme);
		});

	}, [roomId, serverUrl]);
};

このロジックをコンポーネントから関数(onReceiveMessage())として渡せるようにしましょう(サンプル008)。

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

	// useChatRoom({ roomId, serverUrl });
	useChatRoom({
		roomId,
		serverUrl,
		onReceiveMessage(connectionInfo, theme) {
			showNotification('New connection: ' + connectionInfo, theme);
		}
	});

};

カスタムフックuseChatRoomは、コンポーネントの渡す引数オブジェクトからイベントハンドラ関数(onReceiveMessage)を取り出し、メッセージが届いたら呼び出します。

src/useChatRoom.ts
// export const useChatRoom = ({ serverUrl, roomId }: Props) => {
export const useChatRoom = ({ serverUrl, roomId, onReceiveMessage }: Props) => {
	useEffect(() => {

		connection.on("message", (connectionInfo, theme) => {
			// showNotification("New connection: " + connectionInfo, theme);
			onReceiveMessage(connectionInfo, theme);
		});

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

関数onReceiveMessageは、エフェクトの依存配列に含めなければなりません。すると、動作はするものの、コンポーネントが再レンダーされるたびに、チャットは再接続されてしまいます。望ましいことではありません。

サンプル008■React + TypeScript: Reusing Logic with Custom Hooks 08

ハンドラ関数(onReceiveMessage)を依存配列から除くには、エフェクトイベントで包むことです。ChatRoomコンポーネントが再レンダーされるたびにチャットを再接続することはなくなります。動作と各モジュールのコードは以下のサンプル009でお確かめください。

src/useChatRoom.ts
export const useChatRoom = ({ serverUrl, roomId, onReceiveMessage }: Props) => {
	const onMessage = useEffectEvent(onReceiveMessage);
	useEffect(() => {

		connection.on("message", (connectionInfo, theme) => {
			// onReceiveMessage(connectionInfo, theme);
			onMessage(connectionInfo, theme);
		});

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

サンプル009■React + TypeScript: Reusing Logic with Custom Hooks 09

カスタムフックuseChatRoomの引数オブジェクトに、イベントハンドラonReceiveMessageが加えられました。コンポーネントはそれさえ知っていれば、フックが内部的にどう処理するか気にする必要はありません。これがカスタムフックの優れた力です。

カスタムフックはいつ使うか

コードが共有化できるからといって、何でもカスタムフックにすることはありません。たとえば、簡単なカスタムフックの例としてご紹介した前掲サンプル005のuseFormInputは、実践ではあえてロジックを切り出すまでもないでしょう。

けれど、エフェクトを書くときはつねに、カスタムフックに包むことでよりわかりやすくできないか考えてください。エフェクトはそうたびたび使うべきではありません。エフェクトを用いるのはつぎのふたつの場合でしょう。

  • 「Reactの外に出て」外部システムと同期する。
  • ReactのAPIに組み込まれていない処理を行う。

これらの処理は、カスタムフックに包むことで、何をしたいのか、データフローがどうなるのかはっきりするはずです。

たとえば、以下のコンポーネントShippingFormはつぎのふたつのドロップダウンを備えます。

  • countryに応じた都市のリスト(cities)。
  • 選択された都市(city)内にある地区のリスト(areas)。
ShippingForm.tsx
function ShippingForm({ country }: Props) {
	const [cities, setCities] = useState<string[] | null>(null);
	// エフェクトはcountryに応じてcitiesを取得
	useEffect(() => {
		let ignore = false;
		fetch(`/api/cities?country=${country}`)
			.then((response) => response.json())
			.then((json) => {
				if (!ignore) {
					setCities(json);
				}
			});
		return () => {
			ignore = true;
		};
	}, [country]);
	const [city, setCity] = useState<string | null>(null);
	const [areas, setAreas] = useState<string[] | null>(null);
	// エフェクトはcityに応じてareasを取得
	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]);

}

コードには重複が見られて冗長です。けれど、ふたつのエフェクトは互いに独立させることが適切といえます。それぞれは異なるものを同期しているからです。ひとつのエフェクトにまとめるべきではありません。替わりに、ふたつに共通のロジックをカスタムフック(useData)に切り出せば、ShippingFormコンポーネントは簡素化できるでしょう。

useData.ts
function useData(url: string | null) {
	const [data, setData] = useState(null);
	useEffect(() => {
		if (url) {
			let ignore = false;
			fetch(url)
				.then((response) => response.json())
				.then((json) => {
					if (!ignore) {
						setData(json);
					}
				});
			return () => {
				ignore = true;
			};
		}
	}, [url]);
	return data;
}

これで、ShippingFormコンポーネントにあったふたつのエフェクトは、カスタムフックuseDataの呼び出しに替えられます。

ShippingForm.tsx
function ShippingForm({ country }: Props) {
	const cities = useData(`/api/cities?country=${country}`);
	const [city, setCity] = useState<string | null>(null);
	const areas = useData(city ? `/api/areas?city=${city}` : null);

}

カスタムフックに切り出したことにより、コードのデータの流れがはっきりしたでしょう。フックuseDataurlを与えれば、返されるのはdataです。エフェクトはuseDataの中に「隠され」ました。誰かがShippingFormコンポーネントに手を加えようとしたときに、不要な依存値が加えられてしまうことも防げるでしょう。このようにコードの切り出しを進めていけば、アプリケーションの多くのエフェクトがカスタムフックになるはずです。

カスタムフックは具体的で高レベルな用途に使う

カスタムフックは、まず名前を選ぶことから始めましょう。名前を決めづらいというのは、コンポーネントに含まれるエフェクトが他のロジックと密接に絡みすぎているのかもしれません。それは、まだ切り出す準備ができていないということです。カスタムフックの名前は、あまりコードを書きなれない人にもわかりやすいことを目指しましょう。何をするカスタムフックなのか、引数がどのような値で、戻り値は何かということです。

  • useData(url)
  • useImpressionLog(eventName, extraData)
  • useChatRoom(options)

外部システムと同期する場合には、カスタムフックの名前は技術的になり、システムの専門用語が含まれるかもしれません。それでも、そのシステムを使う人にわかるなら構わないでしょう。

  • useMediaQuery(query)
  • useSocket(url)
  • useIntersectionObserver(ref, options)

カスタムフックの用途はハイレベルにかぎるべきです。「ライフサイクル」のカスタムフックを書くのは止めてください。つまり、useEffect APIの替りにしたり、便利なラッパーとして使おうとすることです。

  • 🔴 useMount(fn)
  • 🔴 useEffectOnce(fn)
  • 🔴 useUpdateEffect(fn)

たとえば、つぎのuseMountフックは、「マウント時」にのみコードを実行しようとしています。

ChatRoom.tsx
function ChatRoom({ roomId: string }) {
	const [serverUrl, setServerUrl] = useState('https://localhost:1234');
	// 🔴 NG: 「ライフサイクル」のカスタムフックを使用
	useMount(() => {
		const connection = createConnection({ roomId, serverUrl });
		connection.connect();
		post('/analytics/event', { eventName: 'visit_chat' });
	});

}

// 🔴 NG: 「ライフサイクル」のカスタムフックを定義
function useMount(fn: () => void) {
	useEffect(() => {
		fn();
	}, []); // 🔴 NG: 依存値fnが不足
}

「ライフサイクル」のカスタムフックuseMountは、Reactの設計思想に合いません。たとえば、このコード例には誤りがあります(roomIdserverUrlの変更に「反応」しないことです)。けれど、リンターは警告を示しません。リンターが確かめるのはuseEffectの直接的な呼び出しだけだからです。カスタムフックのことまではわかりません。

エフェクトを書くのであれば、まず用いるのは直接ReactのAPIです。

ChatRoom.tsx
function ChatRoom({ roomId: string }) {
	const [serverUrl, setServerUrl] = useState('https://localhost:1234');
	// ✅ Good: two raw Effects separated by purpose
	useEffect(() => {
		const connection = createConnection({ serverUrl, roomId });
		connection.connect();
		return () => connection.disconnect();
	}, [serverUrl, roomId]);
	useEffect(() => {
		post('/analytics/event', { eventName: 'visit_chat', roomId });
	}, [roomId]);

}

そのうえで、異なるハイレベルの用途について、それぞれ必要があればカスタムフックに切り出しましょう。

function ChatRoom({ roomId }) {
	const [serverUrl, setServerUrl] = useState('https://localhost:1234');
	// ✅ OK: カスタムフックの名前が目的を表す
	useChatRoom({ serverUrl, roomId });
	useImpressionLog('visit_chat', { roomId });

}

優れたカスタムフックは、すべきことが絞り込まれて、コードの呼び出しを宣言的にします。たとえば、useChatRoom(options)の呼び出しはチャットルームに接続するだけです。他方で、useImpressionLog(eventName, extraData)はアナリティクスへの表示ログの送信のみを担います。用途を限定しない抽象的なカスタムフックは、将来的に問題の解決より発生の原因になるかもしれません。

ここで、前掲サンプル003に戻りましょう。カスタムフックuseOnlineStatususeStateuseEffectを組み合わせて実装しましした。けれど、このコードは不十分で、まだ見落としがあります。たとえば、コンポーネントがマウントされたとき、isOnlineの初期値はtrueです。でも、ネットワークはすでにオフラインかもしれません。ブラウザのnavigator.onLine APIを使えば調べられます。ただし、直接使うと、サーバが初期HTMLを生成する場合に動作しません。

src/useOnlineStatus.ts
export const useOnlineStatus = () => {
	const [isOnline, setIsOnline] = useState(true);
	useEffect(() => {
		const handleOnline = () => {
			setIsOnline(true);
		};
		const handleOffline = () => {
			setIsOnline(false);
		};
		window.addEventListener('online', handleOnline);
		window.addEventListener('offline', handleOffline);
		return () => {
			window.removeEventListener('online', handleOnline);
			window.removeEventListener('offline', handleOffline);
		};
	}, []);
	return isOnline;
};

React 18に備わった、これらの問題が解決できる専用のAPIであるuseSyncExternalStore(構文の説明は後述)を使いましょう。書き替えるのは、カスタムフックuseOnlineStatusの実装だけです。

src/useOnlineStatus.tsx
const subscribe = (callback: () => void) => {
	window.addEventListener('online', callback);
	window.addEventListener('offline', callback);
	return () => {
		window.removeEventListener('online', callback);
		window.removeEventListener('offline', callback);
	};
};
export const useOnlineStatus = () => {
	/* const [isOnline, setIsOnline] = useState(true);
	useEffect(() => {

	}, []);
	return isOnline; */
	return useSyncExternalStore(
		subscribe,
		() => navigator.onLine, // クライアントの値を取得する
		() => true // サーバーの値を取得する
	);
};

コンポーネントからのカスタムフックの呼び出しに変わりはありません。つまり、コンポーネントにはまったく触れることなく移行ができました(サンプル010)。

src/StatusBar.tsx
export const StatusBar: FC = () => {
	const isOnline = useOnlineStatus();

};
src/SaveButton.tsx
export const SaveButton: FC = () => {
	const isOnline = useOnlineStatus();

};

サンプル010■React + TypeScript: Reusing Logic with Custom Hooks 10

これも、エフェクトをカスタムフックで包むことが有益な理由のひとつです。具体的には、つぎのような点が挙げられるでしょう。

  1. データフローがエフェクトにどう入って出てゆくのかはっきりします。
  2. コンポーネントについて考えるべきは意図です。エフェクトの実装がどうなっているかは気にしなくて構いません。
  3. Reactが新たな機能を追加したときも、コンポーネントは変更せずに済みます。エフェクトを置き替えればよいからです。

アプリケーションのコンポーネントから、共通する定型コードをカスタムフックに切り出すことは役立つでしょう。コンポーネントのコードが表すのは意図になり、コンポーネント内にエフェクトをたびたび書くことは避けられるからです。多くのカスタムフックがReactコミュニティにはあり、メンテナンスされています。

useSyncExternalStoreの構文

useSyncExternalStoreは、外部データストアから値を読み取るフックです。

構文
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)

引数

  • subscribe: ストアに登録(サブスクライブ)する関数です。登録解除のための関数を返さなければなりません。
  • getSnapshot: コンポーネントが必要なデータのスナップショットをストアから読み取って返す関数です。
  • getServerSnapshot(省略可能): ストアのデータの初期スナップショットを返す関数で、サーバレンダリング中、およびクライアント上でのサーバレンダリングされたコンテンツのハイドレーション中にのみ用いられます。

戻り値
レンダリングロジックで使えるストアの現在のスナップショットです。

Reactはデータ取得のための組み込みソリューションを提供するか

詳しくはまだ検討しています。けれど、将来予定しているのは、データ取得のつぎのような書き方です。

import { use } from 'react'; // 未実装

function ShippingForm({ country }: Props) {
	const cities = use(fetch(`/api/cities?country=${country}`));
	const [city, setCity] = useState<string | null>(null);
	const areas = city ? use(fetch(`/api/areas?city=${city}`)) : null;

}

アプリケーションが予め前掲コード例のuseDataのようなカスタムフックを使っていれば、新しく推奨される手法に移行するのも楽でしょう。コンポーネントごとに内部のエフェクトを、いちいち手書きで修正せずに済むからです。ただし、カスタムフックへの切り出しは必須ではありません。どちらがよいかは、選択次第です。

ロジックの切り分け方はひとつではない

アプリケーションのロジックの切り分け方はひとつではありません。フェードインアニメーションを標準JavaScriptで実装したいとしましょう。用いるのはブラウザのrequestAnimationFrame APIです。まずは、エフェクトにアニメーションループを定めます(サンプル011)。フレームごとに、DOMノードのopacityを初期値の0から1になるまで更新するアニメーションです。DOMノードはrefで保持しなければなりません。

src/Welcome.tsx
export const Welcome: FC = () => {
	const ref = useRef<HTMLHeadingElement>(null);
	useEffect(() => {
		const duration = 1000;
		const node = ref.current;
		let startTime: number | null = performance.now();
		let frameId: number | null = null;
		const onProgress = (progress: number) => {
			if (!node) return;
			node.style.opacity = String(progress);
		};
		const onFrame = (now: number) => {
			const timePassed = startTime ? now - startTime : 0;
			const progress = Math.min(timePassed / duration, 1);
			onProgress(progress);
			if (progress < 1) {
				frameId = requestAnimationFrame(onFrame);
			}
		};
		const start = () => {
			onProgress(0);
			startTime = performance.now();
			frameId = requestAnimationFrame(onFrame);
		};
		const stop = () => {
			frameId && cancelAnimationFrame(frameId);
			startTime = null;
			frameId = null;
		};
		start();
		return () => stop();
	}, []);

};

サンプル011■React + TypeScript: Reusing Logic with Custom Hooks 11

このコード例ではカスタムフックは使いませんでした。つぎは、フェードインアニメーションさせるエフェクトのロジックをカスタムフックuseFadeInに切り出しましょう(サンプル012)。

src/useFadeIn.ts
export const useFadeIn = (
	ref: RefObject<HTMLHeadingElement>,
	duration: number
) => {
	useEffect(() => {
		const node = ref.current;
		let startTime: number | null = performance.now();
		let frameId: number | null = null;
		const onProgress = (progress: number) => {
			if (!node) return;
			node.style.opacity = String(progress);
		};
		const onFrame = (now: number) => {
			const timePassed = startTime ? now - startTime : 0;
			const progress = Math.min(timePassed / duration, 1);
			onProgress(progress);
			if (progress < 1) {
				frameId = requestAnimationFrame(onFrame);
			}
		};
		const start = () => {
			onProgress(0);
			startTime = performance.now();
			frameId = requestAnimationFrame(onFrame);
		};
		const stop = () => {
			frameId && cancelAnimationFrame(frameId);
			startTime = null;
			frameId = null;
		};
		start();
		return () => stop();
	}, [ref, duration]);
};
src/Welcome.tsx
export const Welcome: FC = () => {
	const ref = useRef<HTMLHeadingElement>(null);
	/* useEffect(() => {

	}, []); */
	useFadeIn(ref, 1000);

};

サンプル012■React + TypeScript: Reusing Logic with Custom Hooks 12

さらに、別の新たなカスタムフックへの切り出しもできます。useFadeInの中のアニメーション設定ロジックです。カスタムフックuseAnimationLoopとしましょう(サンプル013)。

src/useAnimationLoop.ts
export const useAnimationLoop = (
	isRunning: boolean,
	drawFrame: (timePassed: number) => void
) => {
	const onFrame = useEffectEvent(drawFrame);
	useEffect(() => {
		if (!isRunning) {
			return;
		}
		const startTime = performance.now();
		let frameId: number | null = null;
		const tick = (now: number) => {
			const timePassed = now - startTime;
			onFrame(timePassed);
			frameId = requestAnimationFrame(tick);
		};
		tick(performance.now());
		return () => {
			if (frameId) cancelAnimationFrame(frameId);
		};
	}, [isRunning]);
};
src/useFadeIn.ts
export const useFadeIn = (
	ref: RefObject<HTMLHeadingElement>,
	duration: number
) => {
	const [isRunning, setIsRunning] = useState(true);
	/* useEffect(() => {

	}, [ref, duration]); */
	useAnimationLoop(isRunning, (timePassed: number) => {
		const progress = Math.min(timePassed / duration, 1);
		if (ref.current) {
			ref.current.style.opacity = String(progress);
		}
		if (progress === 1) {
			setIsRunning(false);
		}
	});
};

なお、useAnimationLoopには実験的なAPIであるuseEffectEventを用いました。エフェクトから関数の呼び出しを切り出し、依存値に含めないためです。

サンプル013■React + TypeScript: Reusing Logic with Custom Hooks 13

カスタムフックへのロジックの切り出し方は、ひとつではありません。コード分割の線引きを決めるのは開発者です。今回のコード例なら、カスタムフックuseFadeInのエフェクトからアニメーションのロジックをクラス(FadeInAnimation)に移す手もあるでしょう(サンプル014)。

src/animation.ts
export class FadeInAnimation {
	node: HTMLHeadingElement | null;
	duration: number;
	startTime: number | null;
	frameId: number | null;
	constructor(node: HTMLHeadingElement | null) {
		this.node = node;
		this.duration = 0;
		this.startTime = null;
		this.frameId = null;
	}
	start(duration: number) {
		this.duration = duration;
		this.onProgress(0);
		this.startTime = performance.now();
		this.frameId = requestAnimationFrame(() => this.onFrame());
	}
	onFrame() {
		const timePassed = this.startTime ? performance.now() - this.startTime : 0;
		const progress = Math.min(timePassed / this.duration, 1);
		this.onProgress(progress);
		if (progress < 1) {
			this.frameId = requestAnimationFrame(() => this.onFrame());
		} else {
			this.stop();
		}
	}
	onProgress(progress: number) {
		if (!this.node) return;
		this.node.style.opacity = String(progress);
	}
	stop() {
		this.frameId && cancelAnimationFrame(this.frameId);
		this.startTime = null;
		this.frameId = null;
		this.duration = 0;
	}
}
src/useFadeIn.ts
export const useFadeIn = (
	ref: RefObject<HTMLHeadingElement>,
	duration: number
) => {
	useEffect(() => {
		const animation = new FadeInAnimation(ref.current);
		animation.start(duration);
		return () => animation.stop();
	}, [ref, duration]);
};

サンプル014■React + TypeScript: Reusing Logic with Custom Hooks 14

エフェクトを用いると、Reactは外部システムと同期できます。エフェクト同士の調整(たとえば、複数のアニメーションの連動)がかなり増えてくるようになったら、前掲コード例(サンプル014)のようにロジックをエフェクトやフックから完全に切り出してしまうことも検討に値するでしょう。そうすれば、外に出したロジックは「外部システム」になります。エフェクトは簡素に保てるでしょう。Reactの外に移したシステムに、メッセージを送れば済むからです。

この項のコード例は、フェードインのロジックをJavaScriptで書くという想定でした。ただし、今回の具体的なフェードインアニメーションの例で、もっと簡潔で効率的なのは標準のCSSアニメーションで実装することです(サンプル015)。このように、フックを使わずに済むこともあります。

src/welcome.css
.welcome {
	color: white;
	padding: 50px;
	text-align: center;
	font-size: 50px;
	background-image: radial-gradient(
		circle,
		rgba(63, 94, 251, 1) 0%,
		rgba(252, 70, 107, 1) 100%
	);
	animation: fadeIn 1000ms;
}
@keyframes fadeIn {
	0% {
		opacity: 0;
	}
	100% {
		opacity: 1;
	}
}
src/Welcome.tsx
export const Welcome: FC = () => {
	return (
		<h1 className="welcome">Welcome</h1>
	);
};

サンプル015■React + TypeScript: Reusing Logic with Custom Hooks 15

まとめ

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

  • コンポーネント間でロジックを共有するのがカスタムフックです。
  • カスタムフックの名前はuseではじめて頭文字が大文字の単語を続けてください。
  • カスタムフックが共有するのは状態をもつロジックです。状態そのものではありません。
  • リアクティブな値はフックの間で受け渡しできます。その値はつねに最新です。
  • フックはコンポーネントが再レンダーされるごとに、再実行されます。
  • カスタムフックのコードは、コンポーネントと同じく純粋でなければなりません。
  • カスタムフックがエフェクトのために受け取るイベントハンドラは、エフェクトイベントで包んで切り出しましょう。
  • useMountのような抽象的なカスタムフックは書かないでください。目的はいつも限定するようにします。
  • コードをどこでどう切り分けるか線引きするのは開発者です。
0
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
0
1