Reactとジェネリクスで、型の付いた一覧と選択の部品を作る方法
一覧や選択の部品を、どんなデータにも使えるように作ると、props の型が any や unknown になりがちです。コンポーネントを function List<T>(props: ListProps<T>) のようにジェネリクスで書くと、渡した配列の型が renderItem や onChange まで伝わり、データの種類ごとに部品を作り分けなくても、型が付いたまま使えます。
筆者は検証環境で、ジェネリクスで書いた List と Select を作り、型チェックの結果と、出力される HTML、Chromium での動きを確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 141 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2 です。型チェックは tsc、HTML の確認は react-dom/server の renderToStaticMarkup、ブラウザでの確認は、esbuild で変換したコード(開発用のビルド)を Chromium で動かし、Playwright で操作して行いました。
一覧の部品を、型引数 T で書く
次の List は、配列 items を受け取り、1件ずつ renderItem で表示します。T が、一覧に並べるデータの型です。
import type { Key, ReactNode, Ref } from 'react';
type ListProps<T> = {
items: T[];
getKey: (item: T) => Key;
renderItem: (item: T) => ReactNode;
emptyText?: string;
ref?: Ref<HTMLUListElement>;
};
export function List<T>({ items, getKey, renderItem, emptyText = 'ありません', ref }: ListProps<T>) {
if (items.length === 0) return <p>{emptyText}</p>;
return (
<ul ref={ref}>
{items.map((item) => (
<li key={getKey(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
List はデータの中身を知りません。そのため、一覧の key に何を使うかも、getKey で使う側に決めてもらいます。
使う側では、T を書く必要はありません。
type Cat = { id: number; name: string; age: number };
type Food = { code: string; title: string; price: number };
<List items={cats} getKey={(c) => c.id} renderItem={(c) => `${c.name}(${c.age}歳)`} />
<List items={foods} getKey={(f) => f.code} renderItem={(f) => `${f.title} ${f.price}円`} />
cats の型が Cat[] なので、T は Cat に決まり、getKey と renderItem の引数 c も Cat になります。TypeScript の ジェネリクスの説明にある、型引数の推論(type argument inference)です。検証環境で tsc を通すと、エラーは出ませんでした。HTML は次のとおりです(読みやすいように、タグの前で改行を入れています)。
<ul>
<li>たま(3歳)</li>
<li>みけ(5歳)</li>
<li>くろ(1歳)</li></ul>
<ul>
<li>カリカリ 800円</li>
<li>ちゅーる 300円</li></ul>
c の型は Cat なので、Cat にないプロパティを書くと、型チェックで止まります。
<List items={cats} getKey={(c) => c.id} renderItem={(c) => c.title} />
errors/WrongUsage.tsx(11,68): error TS2339: Property 'title' does not exist on type 'Cat'.
選択の部品では、選んだ値も T で返す
次の Select は、<select> で1つを選び、選んだデータそのものを onChange で返します。value と onChange の型にも T を使っています。
type SelectProps<T> = {
label: string;
options: T[];
value: T | null;
onChange: (value: T | null) => void;
getKey: (option: T) => string;
getLabel: (option: T) => string;
};
export function Select<T>({ label, options, value, onChange, getKey, getLabel }: SelectProps<T>) {
return (
<label>
{label}
<select
value={value === null ? '' : getKey(value)}
onChange={(e) => {
const found = options.find((option) => getKey(option) === e.target.value);
onChange(found ?? null);
}}
>
<option value="">選んでください</option>
{options.map((option) => (
<option key={getKey(option)} value={getKey(option)}>
{getLabel(option)}
</option>
))}
</select>
</label>
);
}
<option> の value は文字列なので、getKey は文字列を返すようにしました。選ばれた文字列から options の中のデータを探し、そのデータを onChange に渡します。
使う側では、useState のセッターをそのまま onChange に渡せます。
const [cat, setCat] = useState<Cat | null>(null);
<Select
label="猫"
options={cats}
value={cat}
onChange={setCat}
getKey={(c) => String(c.id)}
getLabel={(c) => c.name}
/>
getKey に (c) => c.id と書くと、数値を返すので、Type 'number' is not assignable to type 'string'. という型エラーになりました。
T はすべての props から決まる
T は options だけで決まるわけではありません。value や onChange も手がかりになります。猫の Select に、ごはんを受け取る関数を間違えて渡した場合を試しました。
const showFood = (food: Food | null) => console.log(food?.title);
<Select label="猫" options={cats} value={cat} onChange={showFood} getKey={(c) => String(c.id)} getLabel={(c) => c.name} />
間違えたのは onChange ですが、エラーは options や value、getKey、getLabel のほうに出ました(エラーの続きの説明は省いています)。
errors/WrongUsage.tsx(12,25): error TS2322: Type 'Cat[]' is not assignable to type 'Food[]'.
errors/WrongUsage.tsx(12,40): error TS2322: Type 'Cat | null' is not assignable to type 'Food | null'.
errors/WrongUsage.tsx(12,96): error TS2339: Property 'id' does not exist on type 'Food'.
errors/WrongUsage.tsx(12,120): error TS2339: Property 'name' does not exist on type 'Food'.
この例では、T が Food に決まり、Food と合わないほかの props がエラーになっています。間違いは型チェックで止まりますが、エラーの場所が間違えた箇所とずれることがあります。
T を自分で決めたいときは、JSX でも <Select<Cat> ...> のように型引数を書けます。TypeScript 2.9 から、JSX の要素に型引数を渡せるようになりました。同じ間違いを <Select<Cat> ...> で書くと、エラーは onChange に出ました(続きの説明は省いています)。
errors/ExplicitType.tsx(9,55): error TS2322: Type '(food: Food | null) => void' is not assignable to type '(value: Cat | null) => void'.
空の配列を渡すと、T は never になる
items={[]} のように空の配列をそのまま渡すと、T を決める手がかりがなくなります。検証では T が never になり、getKey や renderItem の中がエラーになりました。
<List items={[]} getKey={(c) => c.id} renderItem={(c) => c.name} />
errors/EmptyArray.tsx(7,41): error TS2339: Property 'id' does not exist on type 'never'.
errors/EmptyArray.tsx(7,66): error TS2339: Property 'name' does not exist on type 'never'.
<List<Cat> items={[]} ...> と型引数を書くと、エラーは出ませんでした。ごはんの一覧で items={[] as Food[]} のように配列に型を付けた場合も、エラーは出ませんでした。
T に条件を付けると、getKey を省ける
どのデータにも id があると決めてよいなら、T extends { id: Key } と条件(ジェネリクスの制約)を付けます。部品の中で item.id を使えるので、getKey が要らなくなります。
type IdListProps<T extends { id: Key }> = {
items: T[];
renderItem: (item: T) => ReactNode;
};
export function IdList<T extends { id: Key }>({ items, renderItem }: IdListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{renderItem(item)}</li>
))}
</ul>
);
}
<IdList items={cats} ...> は通り、id のない Food の配列を渡すと、型エラーになりました(同じ行の renderItem に出たエラーは省いています)。
errors/Constraint.tsx(23,15): error TS2322: Type 'Food[]' is not assignable to type '{ id: Key; }[]'.
Property 'id' is missing in type 'Food' but required in type '{ id: Key; }'.
アロー関数で書くときは <T,> にする
.tsx のファイルでは、アロー関数に <T> と書くと、JSX のタグとして読まれてしまいます。
export const NoComma = <T>(props: { items: T[] }) => <p>{props.items.length}件</p>;
最初のエラーは次のとおりです(続くエラー4件は省いています)。
errors/ArrowGeneric.tsx(1,25): error TS17008: JSX element 'T' has no corresponding closing tag.
<T,> のようにカンマを付けると、型引数として読まれ、エラーは出ませんでした。function で書けば、この問題は起きません。
ref は、props として受け取れば T が残る
React 19 では、関数コンポーネントが ref を普通の props として受け取れます。最初の List は、ref を props の型に入れているので、T を付けたまま ref も受け取れます。Chromium で動かすと、<List ref={listRef} items={cats} ...> の listRef.current は UL の要素で、子の要素は3つでした。
React 18 までの書き方のように forwardRef で包むと、T が消えます。
const OldList = forwardRef(function OldList<T>(
{ items, renderItem }: OldListProps<T>,
ref: Ref<HTMLUListElement>,
) {
return <ul ref={ref}>{items.map((item, i) => <li key={i}>{renderItem(item)}</li>)}</ul>;
});
<OldList items={cats} renderItem={(c) => c.name} />
errors/ForwardRef.tsx(17,51): error TS18046: 'c' is of type 'unknown'.
renderItem の c が Cat にならず、unknown になりました。React TypeScript Cheatsheet の「Generic Components with Refs」でも、ジェネリクスのコンポーネントでは ref を手で扱う方法が紹介されています。React 19 なら、props として受け取るのがいちばん簡単です。
Chromium で選んだ結果
List と Select を1つの画面に置き、猫とごはんを選びました。
# 最初の表示: 選んだ猫:なし/ごはん:なし
# 猫で「みけ」を選んだ後: 選んだ猫:みけ(5歳)/ごはん:なし
# ごはんで「ちゅーる」を選んだ後: 選んだ猫:みけ(5歳)/ごはん:ちゅーる(300円)
# 猫を「選んでください」に戻した後: 選んだ猫:なし/ごはん:ちゅーる(300円)
同じ Select が、猫では Cat を、ごはんでは Food を返しています。cat.age や food.price のように、それぞれの型のプロパティをそのまま表示に使えました。「選んでください」に戻すと、猫の表示が「なし」に戻りました(onChange に null が渡ったためです)。

props の型を ListProps<T> のようにし、コンポーネントを function List<T> で書きます。データの中身に関わる処理(key、表示する文字)は、getKey や renderItem のような関数で使う側に任せます。
型引数 T で、部品をデータの種類から切り離す
ジェネリクスで書いた List と Select は、渡した配列から T が決まり、renderItem や onChange の引数にもその型が付きます。検証では、ほかの型のプロパティを使ったり、別の型を受け取る関数を渡したりすると、型チェックで止まりました。ただし、エラーの出る場所がずれることや、空の配列で T が never になることがあるので、そのときは <Select<Cat>> のように型引数を書きます。





