React コンポーネントの props の型を呼び出し側で指定する
🌼 はじめに
たまにコンポーネントの Props の型を呼び出し側で指定したいときがあります。
例えば、以下のようにカスタムで作った Dropdown
のコンポーネントがあるとしましょう。(スタイリングは省略してます)
type DropdownProps = {
options: { label: string; value: string }[];
onSelect?: (value: string) => void;
};
export const Dropdown = ({ options, onSelect }: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const handleOptionClick = (value: string) => {
if (onSelect) onSelect(value);
setIsOpen(false);
};
const handleToggle = () => {
setIsOpen((prev) => !prev);
};
return (
<div>
<button onClick={handleToggle}>Select an option</button>
{isOpen && (
<ul>
{options.map((option) => (
<li
key={option.value}
onClick={() => handleOptionClick(option.value)}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
};
そして Dropdown
コンポーネントを呼び出します。選択肢をセレクトするときに何かのAPIを叩く動きを想定して作りました。
const OPTIONS = [
{ label: "新しい順", value: "newest" },
{ label: "古い順", value: "oldest" },
];
export const Component = () => {
const handleSelect = (value: string) => {
fetchData(value);
};
return <Dropdown options={OPTIONS} onSelect={handleSelect} />;
};
ですが、もし fetchData
の引数の型が "newest" | "oldest"
だとしたら?
const fetchData = async (value: "newest" | "oldest") => {
// value を用いって API 叩く
};
以下のように fetchData(value)
で型エラーが発生してしまいます。
const handleSelect = (value: string) => {
// Argument of type string is not assignable to parameter of type "newest" | "oldest"
fetchData(value);
};
value
の型は string
なのに、fetchData
は "newest" | "oldest"
だけ引数として受け取るので、型が合わなくなり、エラーになります[1]。
困りましたね。
でも Dropdown
の Props の形を絞るのは無理でしょう。Dropdown
は汎用的に使い回すコンポーネントなので、特定の型に変更しちゃうことは不可能です。
だとしたら呼び出し側でなんとかするしかないです。まあ一応1番手っ取り早い方法は型アサーションを使うことですね。
const handleSelect = (value: string) => {
fetchData(value as "newest" | "oldest");
};
でも形アサーションは型を上書きする、つまり型に嘘を付くことが可能なので本当に仕方ないとき以外は極力使いたくないです。
その一心で色々調べた結果、コンポーネントに型パラメータを渡すことで解決できました。その方法を共有します。
1. コンポーネントの型パラメータ
今のプロジェクトではコンポーネントをアロー関数で定義してます。
ちなみにアロー関数で型パラメータを使う方法はこちらです。
const func = <T>(parameter: T) => {};
型パラメータは引数のカッコの前に書いて、その型パラメータを関数の中で使う感じです。
アロー関数で定義するコンポーネントも同じように型パラメータを使うことができます。[2]
type ComponentProps<T> = {};
const Component = <T,>(props: ComponentProps<T>) => {};
コンポーネント引数のカッコの前に型パラメータを書いて、それをコンポーネントの内部で使います。ComponentProps
もジェネリックス型にしたら、ComponentProps
でも型パラメータを使えるようになります。
そして呼び出し側で型パラメータを渡すときは、以下のように渡します。
const Parent = () => {
return <Component<CustomType> />;
};
CustomType
のところに指定したい型を指定したら、呼び出し側でコンポーネントの props の形を指定できます。
このやり方で、Dropdown
も型パラメータを受け取るコンポーネントにしてみましょう。
// DropdownProps のほうもジェネリックス型にする
type DropdownProps<T> = {
options: { label: string; value: T }[];
onSelect?: (value: T) => void;
};
// 型パラメータ `T` を受け取る
export const Dropdown = <T extends string>({
options,
onSelect,
// DropdownProps に型パラメータを渡す
}: DropdownProps<T>) => {
// ...
// イベントハンドラーの引数の型も合わせる
const handleOptionClick = (value: T) => {
if (onSelect) onSelect(value);
setIsOpen(false);
};
// ...
}
呼び出し側で props の型を指定するとはいえ、value
の型は string
範囲内の想定なので、extends string
で最低限の制約はおきました。
その後呼び出し側で型パラメータを渡します。
// 型アノテーションする
const OPTIONS: { label: string; value: "newest" | "oldest" }[] = [
{ label: "新しい順", value: "newest" },
{ label: "古い順", value: "oldest" },
];
export const Component = () => {
const handleSelect = async (value: "newest" | "oldest") => {
await fetchData(value);
};
return (
// 型パラメータを渡す
<Dropdown<"newest" | "oldest"> options={OPTIONS} onSelect={handleSelect} />
);
};
ちなみに先のままだと OPTIONS
の型が { label: string, value: string }[]
になり[3]、こちらで型が合わなくなるのでちゃんと型アノテーションしてあげます。
これで型があうようになりました!
このやり方だと Dropdown
の汎用性を守りつつ型を厳密にできるのでよしと思います。
2. 型推論を利用する
実は呼び出し側で型パラメータを渡す部分は省略できます。
つまり、これだけでも先と同じ動きをさせることができます。
return (
<Dropdown options={OPTIONS} onSelect={handleSelect} />
);
その理由は、props で型を推論できるからじゃないか...と私は思います。
Dropdown
の props の型を見てみると、options
と onSelect
に型パラメータの T
が使われています。
type DropdownProps<T> = {
options: { label: string; value: T }[];
onSelect?: (value: T) => void;
};
逆に言うと、options
と onSelect
から T
を推論できるとも言えるでしょう。
実際 props の片方だけ渡して型情報を確認してみたら、もう片方の型を推論してくれました。
options から onSelect の型を推論してる
onSelect から options の型を推論してる
このように Typescript が型を推論してくれるので、型パラメータを渡すところは省略できるんじゃないかと思います。
いちいち型パラメータ渡さなくてもよくなるので便利ですね!
3. 完成したコード
最終的に完成したコードです。
// Dropdown (汎用コンポーネント)
type DropdownProps<T> = {
options: { label: string; value: T }[];
onSelect: (value: T) => void;
};
export const Dropdown = <T extends string>({
options,
onSelect,
}: DropdownProps<T>) => {
const [isOpen, setIsOpen] = useState(false);
const handleOptionClick = (value: T) => {
if (onSelect) onSelect(value);
setIsOpen(false);
};
const handleToggle = () => {
setIsOpen((prev) => !prev);
};
return (
<div>
<button onClick={handleToggle}>Select an option</button>
{isOpen && (
<ul>
{options.map((option) => (
<li
key={option.value}
onClick={() => handleOptionClick(option.value)}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
};
// コンポーネントの呼び出し側
const OPTIONS: { label: string; value: "newest" | "oldest" }[] = [
{ label: "新しい順", value: "newest" },
{ label: "古い順", value: "oldest" },
];
export const Component = () => {
const handleSelect = (value: "newest" | "oldest") => {
fetchData(value);
};
return <Dropdown options={OPTIONS} onSelect={handleSelect} />;
};
🌷 終わり
アサーション使わずに型を厳密にして型を合わせていくことで快感を感じてます。
-
型システムについて詳しく知りたい方は「集合で理解する Typescript」記事を参考にしてください ↩︎
-
<T,> になる理由は、tsx ファイルだと <T> にしたら JSX に勘違いするからコンマを足してジェネリックであることを明記するためらしいです ↩︎
-
その理由が気になる方は「定数から生成した型が string になった!? 焦らずアサーション(Assertion)を付けよう」記事を参考にしてください ↩︎
Discussion