保守性の高い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
のようなフラグがある場合は、コンポーネントの分離を検討したい。
Discussion
気兼ねなく、
react-hooks/exhaustive-deps
の Auto Fix で自動化出来る日が来てほしい🙏本題
目次の
stateはプリミティブにする
で、良例がuseEffectにdeps自動補完除外コメントを入れる
と同じロード関数
になっています🕵️コード例からして、「分割する」みたいな見出し文になりそうですかね?
ありがとうございます。
ご指摘の通り
ロード関数
は誤りです。修正いたします。
一点、悪例で挙げられているロード関数についてなんですが、
isLoading
がdepsに入っているため無限ループになってしまいますが、本来であればこちらが不要ではないでしょうか?react-hooks/exhaustive-deps
でもisLoading
は推奨されないように思います。ありがとうございます。
ご指摘の通り
isLoading
は不要です。例として不適でした。
修正いたします。