保守性の高いReact hooksコードの指針


前提

本記事は保守性の高いReact hooksコードの指針を記述します。指針はtipsに近いものから原則に近いものまで雑多に含まれます。総じてReact hooksの標準的なAPIを上手く扱う方法が多めです。
これらは保守性の低いコードを反面教師とした私的な経験則に基づきます。(思い出し次第随時追加していきます)
ご留意ください。

解消したい痛み

  • 再現が困難な不具合の発生
  • 容易に無限ループが発生しうる
  • 不具合発生箇所の特定が手間
  • 分岐が多くコードリーディングに手間がかかる

解消する手法

  • useEffectは1ページに1つ
  • useEffectにdeps自動補完除外コメントを入れる
  • stateはプリミティブにする
  • propsにフラグがある場合はコンポーネントを分ける

useEffectは1ページに1つ

悪例: ユーザーイベントの処理

const [foo, setFoo] = useState("foo");

useEffect(() => {
	setFoo("bar");
});

useEffect(() => {
	setFoo("baz");
});

簡略化しておりわかりづらいが、実際には複数のuseEffectに共通するdepsがあり、処理が同時発火するケースが発生していた。かつ、useEffect内に非同期処理があり、処理順がタイミングによって前後するようになっていた。
結果、どのuseEffectが最終的にstateを更新するのかわからない。再現が困難な不具合の発生という痛みが発生していた。

良例:ユーザーイベントの処理

const [foo, setFoo] = useState("foo");

const onClickBar = () => setFoo("bar");

const onClickBaz = () => setFoo("baz");

useEffectを廃し、複数の処理が同時に発火しないようにした。
後述するがWebアプリの多くのケースでuseEffectは必要ではないことがほとんど。useEffectを1ページに1つにするのは現実的と考える。

ブラウザで発生するイベントは以下に大別される。

  • ページ遷移イベント
    初期描画、パス移動など
  • ユーザー操作イベント
    クリック・タップなど
  • ユーザー操作を除くEventListner系イベント
    ネットワーク接続・切断イベントなど
  • サーバー由来イベント
    Websocketやfirebaseリアルタイムアップデートなど

この内useEffectが必要ないのはユーザー操作イベントのみ。だが、多くのアプリではページ遷移イベントとユーザー操作イベント以外はあまり使用しない(経験則)。使用したとしてもグローバルで利用するケース(例:ネットワーク接続状況監視)や、1つのコンポーネントに閉じるケース(例:通知有無の表示)だった。
以上より実際にuseEffectを必要とするのはページ遷移イベントのみであることがほとんど。よってuseEffectを1ページに1つにできる。

useEffectにdeps自動補完除外コメントを入れる

悪例:ロード関数

const [isLoaded, setIsLoaded] = useState<boolean>(false);
const [res, setRes] = useState();

const load = useCallback(async () => {
	try {
		if (isLoaded) {
			return;
		}
		const fooApiRes = await FooApi();
		setRes(fooApiRes);
	} finally {
		setIsLoaded(true);
	}
}, [isLoaded]);

useEffect(() => {
	load();
}, [load]);

useEffectのユースケースとしてページ描画時のロード関数の発火がある。このケースの場合多くはdepsが不要。だが、もしdeps補完機能で補完された場合に容易に無限ループが発生しうる。この痛みを回避したい。(例は2度走るが無限ループしないコードです。適切なものが出せなかったためです)

良例:ロード関数

const [isLoaded, setIsLoaded] = useState<boolean>(false);
const [res, setRes] = useState();

const load = useCallback(async () => {
	try {
		if (isLoaded) {
			return;
		}
		const fooApiRes = await FooApi();
		setRes(fooApiRes);
	} finally {
		setIsLoaded(true);
	}
}, [isLoaded]);

useEffect(() => {
	load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// eslint-disable-next-line react-hooks/exhaustive-depsをdepsに適用することで、depsの自動補完および無限ループを予防する。

stateはプリミティブにする

悪例:オブジェクトのstate更新

const [fooObj, setFooObj] = useState<{
	name: string,
	familyName: string,
	firstName: string,
}>({
	name: "",
	familyName: "",
	firstName: "",
});

const onUpdateFamilyName = useCallback((newFamilyName: string) => {
	setFooObj(obj => {
		...obj,
		name: newFamilyName + obj.firstName,
		familyName: newFamilyName,
	});
}, []);

const onUpdateFirstName = useCallback((newFirstName: string) => {
	setFooObj(obj => {
		...obj,
		name: obj.familyName + newFirstName,
		firstName: newFirstName,
	});
}, []);

簡易な例を書いたため、悪例でもそこまで大きな痛みはない。が、実際fooObjはより巨大なオブジェクトであることが多かった。APIリクエストやAPIレスポンスのオブジェクトが、そのまま1つのstateに詰め込まれることが多いため。
巨大なオブジェクトをstateで管理していると、オブジェクトのフィールドの数だけsetStateが増えうる。
不具合が発生したstateの調査は往々にして更新箇所の調査になる。その調査先がフィールドの数だけ増える。さらにsetStateが別コンポーネントに渡されていた場合、調査の手間がさらに増える。結果、不具合発生箇所の特定が手間という痛みが発生する。

良例:プリミティブなstate更新

const [name, setName] = useState<string>("");
const [familyName, setFamilyName] = useState("");
const [firstName, setFirstName] = useState("");

const onUpdateFamilyName = useCallback((newFamilyName: string) => {
	setName(newFamilyName + firstName);
	setFamilyName(newFamilyName);
}, [firstName]);

const onUpdateFirstName = useCallback((newFirstName: string) => {
	setName(familyName + newFirstName);
	setFirstName(newFirstName);
}, [familyName]);

不具合が発生したstateの調査は、そのsetStateを追うだけで良い。巨大なオブジェクトを扱うよりも調査やコードリーディング工数を削減できる。

propsにフラグがある場合はコンポーネントを分ける

悪例:作成・更新が同一コンポーネント

const DialogContents: React.FC<{isAdd: boolean}> = ({
	isAdd: boolean,
}) => {
	...
	const onSubmit = useCallback(async () => {
		if (isAdd) {
			await FooRegisterApi();
			return;
		}
		await FooUpdateApi();
	}, []);
	
	return (
		<>
			<form onSubmit={onSubmit}>
				<label>Name</label>
				<input value={name} onChange=>{onChangeName}/>
				<label>ID</label>
				<input disabled={!isAdd} value={id} onChange=>{onChangeId}/>
				{isAdd &&
					<label>Email</label>
					<input value={email} onChange=>{onChangeEmail}/>
					<label>PhoneNumber</label>
					<input value={phoneNumber} onChange=>{onChangePhoneNumber}/>
				}
			</form>
		</>
	)
}

コンポーネントに複数の責務がある場合、コンポーネント内の随所に分岐が発生する。よく見るパターンは作成と更新が同一コンポーネントで行われているパターン。作成か更新かをフラグで分岐し、叩くAPIや表示する項目を制御する。結果分岐が多くコードリーディングに手間がかかる痛みを発生させる。

良例:作成・更新は別コンポーネント

const RegisterDialogContents: React.FC = () => {
	...
	const onSubmit = useCallback(async () => {
		await FooRegisterApi();
	}, []);
	
	return (
		<>
			<form onSubmit={onSubmit}>
				<label>Name</label>
				<input value={name} onChange=>{onChangeName}/>
				<label>ID</label>
				<input value={id} onChange=>{onChangeId}/>
				<label>Email</label>
				<input value={email} onChange=>{onChangeEmail}/>
				<label>PhoneNumber</label>
				<input value={phoneNumber} onChange=>{onChangePhoneNumber}/>
			</form>
		</>
	)
}

const UpdateDialogContents: React.FC = () => {
	...
	const onSubmit = useCallback(async () => {
		await FooUpdateApi();
	}, []);
	
	return (
		<>
			<form onSubmit={onSubmit}>
				<label>Name</label>
				<input value={name} onChange=>{onChangeName}/>
				<label>ID</label>
				<input disabled={true} value={id} onChange=>{onChangeId}/>
			</form>
		</>
	)
}

作成と更新は別コンポーネントで行う。従来発生していた随所の分岐が排除されコードリーディングが容易になる。
propsにisFooのようなフラグがある場合は、コンポーネントの分離を検討したい。