Hooks と ジェネリクス(Generics) について勉強してみた
今回の記事は内容が大いに間違っている可能性が高いです。
あまり鵜呑みにしないようご注意ください…。
前提
まず自分が普段よく書くコードが以下の通りです。
import React, {
ComponentPropsWithoutRef,
FC,
useCallback,
useState,
} from "react";
const Hoge: FC = () => {
const [value, setValue] =
useState<ComponentPropsWithoutRef<"input">["value"]>();
const handleChange = useCallback<
NonNullable<ComponentPropsWithoutRef<"input">["onChange"]>
>(({ currentTarget: { value: currentTargetValue } }) => {
setValue(currentTargetValue);
}, []);
return <input onChange={handleChange} value={value} />;
};
export default Hoge;
で、このコードで PR を依頼したところ「ジェネリクスは不要では?」という指定を頂けました。
そこで『ジェネリクスってしっかり勉強したことないな…』と反省し、今一度しっかりと調べ直してみました。
ジェネリクスとは
TypeScript Deed Dive の解説がわかりやすかったです。
ジェネリクスやらジェネリックやらジェネリック型やら表現がややこしいですね…。
このサイト に表現に関する記述がありました。
英語だと、名詞では generics、形容詞が generic です。 なので名詞の generics は、カタカナ語で訳すにしても「ジェネリクス」の方が適切な気はします。実際、Java などではジェネリクスという訳語が一般的です。 ... (本サイトでは一時期、マイクロソフトのドキュメントに訳語を併せるよう努めていたため、名詞形もジェネリックになっているところが多いです。 さすがに変なルールではあるのでジェネリクスと書いているところも多く、混在しているのでご容赦ください。)
ジェネリクスとジェネリックについては品詞的には違うみたいですが、使われている意味合い的には同じみたいですね。
ジェネリック型という表現はよくわからなかったですが、そこまで気にしなくても良さそうです。
以下、今回はジェネリクスという表現で統一して書いていきます。
関数とジェネリクス
一般的にどちらかといえばクラスに対してよく使われるみたいですが、React 的には関数に対して使用するケースのほうが多いと思います。
そのため、今回は関数に絞って書いていこうと思います。
なぜ型引数を使用して関数を作成するかというと「関数自体は曖昧な状態で作成されるが、その関数が使用されるときは型を厳密に与えるため」というのが目的となります。
例としては以下の通りです。
function hoge<T>(fuga: T): T {
return fuga;
}
const moge = hoge<string>("piyo");
hoge 関数が作成されたタイミングでは引数と戻り値の型は型引数で与えられた型であることしか決まっておらず、型が厳密に定まっていません。
ところが hoge が呼び出されたタイミングで型引数に string が与えられたため、引数と戻り値の型が定まる、というフローになっています。
ただ、実際に hoge を呼び出す際に引数に "piyo" という文字列を与えた場合、型が明白なため型推論が働くので、型引数は省略可能となります。
const moge = hoge("piyo");
型引数は型推論の機能によって省略可能というのが、個人的にありがたいけどやっかいだなーと感じます。
つまり逆に言えば、引数の型が厳密でない場合は型引数を与えることが必須、というわけですね。
useState とジェネリクス
最初に書いた例で考えると、例えば useState を以下のように書いてもなんら問題ないわけです。
const [value, setValue] = useState();
とはいえ、このように書いた場合、初期化に与えた引数 undefined によって型推論が働き value の型は undefined で固定されてしまいます。
で、普通に考えて undefined しか許容できない state を切ることなんて一生ないと思いますので、こういうケースでは型引数を与えてあげることが必須になるわけです。
const [value, setValue] = useState();
const handleChange = useCallback<
NonNullable<ComponentPropsWithoutRef<"input">["onChange"]>
>(({ currentTarget: { value: currentTargetValue } }) => {
// currentTargetValue の型は string のため NG
setValue(currentTargetValue);
}, []);
例えば undefined に加えて string も許容する state にしたい場合は、以下のように書いてあげれば良いわけです。
const [value, setValue] = useState<string | undefined>();
const handleChange = useCallback<
NonNullable<ComponentPropsWithoutRef<"input">["onChange"]>
>(({ currentTarget: { value: currentTargetValue } }) => {
// currentTargetValue の型は string のため OK
setValue(currentTargetValue);
}, []);
ところで、今まで自分は input に格納可能な value の型はすべて許容してあげたいので、以下のように書いていたのですが。
// value に格納可能な型は string | number | readonly string[] | undefined
const [value, setValue] =
useState<ComponentPropsWithoutRef<"input">["value"]>();
const handleChange = useCallback<
NonNullable<ComponentPropsWithoutRef<"input">["onChange"]>
>(({ currentTarget: { value: currentTargetValue } }) => {
// currentTargetValue の型は string のため OK
setValue(currentTargetValue);
}, []);
変数を何に割り当てるか、を前提で型を与えるのはイマイチなのかなーという気がしています。
useCallback とジェネリクス
useState については型引数を与えなければいけないケースと省略可能なケースがわかりやすいですが、useCallback の場合も基本は一緒です。
まず useCallback の呼び出しを、以下のように書くことは推奨されません。
const handleChange = useCallback(({ currentTarget: { value } }) => {
setValue(value);
}, []);
こう書いた場合 handleChange の戻り値の型は型推論により void とわかりますが、引数はキーに currrentTarget を持つ object であり、currrentTarget はキーに value を持ち、value の型は any で問題ないということになってしまいます。
で、今まで自分はこの場合『引数の型が曖昧なので、型引数を与えて厳密な型を与えよう』と考え、以下のように書いてきました。
const handleChange = useCallback<
NonNullable<ComponentPropsWithoutRef<"input">["onChange"]>
>(({ currentTarget: { value } }) => {
setValue(value);
}, []);
ただ、これって value の型が曖昧な状態で useCallback の引数に割り当てたことが良くない、という話なのかなーと思っていまして。
正しくは以下のように書いてあげるべきなのかなーと思い始めています。
const handleChange = useCallback(
({ currentTarget: { value } }: { currentTarget: { value: string } }) => {
setValue(value);
},
[],
);
とはいえ、実際に上記のように書く人ってあんまりいないと思うんですよね…多分多くの人がこう書くと思っているんですが、気のせいですかね?
const handleChange = useCallback(
({ currentTarget: { value } }: ChangeEvent<HTMLInputElement>) => {
setValue(value);
},
[],
);
でもこの書き方って引数の型を緩めている状態なので、本当にこれで良いのかな?という気がしなくもないです。
余談ですが、こう書いても一緒ですね。
const handleChange = useCallback(
({
currentTarget: { value },
}: Parameters<
NonNullable<ComponentPropsWithoutRef<"input">["onChange"]>
>[0]) => {
setValue(value);
},
[],
);
ただ、これで「ジェネリクスは不要では?」という指摘が正しいということがよく理解できました。
結論
現時点では、こう書くべきなのかなーと思っています。
import React, { FC, useCallback, useState } from "react";
const Hoge: FC = () => {
const [value, setValue] = useState<string | undefined>();
const handleChange = useCallback(
({
currentTarget: { value: currentTargetValue },
}: {
currentTarget: { value: string };
}) => {
setValue(currentTargetValue);
},
[],
);
return <input onChange={handleChange} value={value} />;
};
export default Hoge;
『変数を何に使うか』を考えて型を与えるべきではない、ということですね。
勉強になりました。
余談
ちなみに、今回はジェネリクスの説明のためにわざと初期値に undefined を与えていますが、普通はこう書くと思います。
import React, {
FC,
useCallback,
useState,
} from "react";
const Hoge: FC = () => {
const [value, setValue] = useState("");
const handleChange = useCallback(
({
currentTarget: { value: currentTargetValue },
}: {
currentTarget: { value: string };
})) => {
setValue(currentTargetValue);
},
[]
);
return <input onChange={handleChange} value={value} />;
};
export default Hoge;