Reactとジェネリクスで、型の付いた一覧と選択の部品を作る方法

​​React​TypeScript,ジェネリクス

一覧や選択の部品を、どんなデータにも使えるように作ると、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 が、一覧に並べるデータの型です。

tsx
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 を書く必要はありません。

tsx
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 は次のとおりです(読みやすいように、タグの前で改行を入れています)。

html
<ul>
<li>たま(3歳)</li>
<li>みけ(5歳)</li>
<li>くろ(1歳)</li></ul>
<ul>
<li>カリカリ 800円</li>
<li>ちゅーる 300円</li></ul>

c の型は Cat なので、Cat にないプロパティを書くと、型チェックで止まります。

tsx
<List items={cats} getKey={(c) => c.id} renderItem={(c) => c.title} />
text
errors/WrongUsage.tsx(11,68): error TS2339: Property 'title' does not exist on type 'Cat'.

選択の部品では、選んだ値も T で返す

次の Select は、<select> で1つを選び、選んだデータそのものを onChange で返します。value と onChange の型にも T を使っています。

tsx
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 に渡せます。

tsx
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 に、ごはんを受け取る関数を間違えて渡した場合を試しました。

tsx
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 のほうに出ました(エラーの続きの説明は省いています)。

text
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 に出ました(続きの説明は省いています)。

text
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 の中がエラーになりました。

tsx
<List items={[]} getKey={(c) => c.id} renderItem={(c) => c.name} />
text
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 が要らなくなります。

tsx
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 に出たエラーは省いています)。

text
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 のタグとして読まれてしまいます。

tsx
export const NoComma = <T>(props: { items: T[] }) => <p>{props.items.length}件</p>;

最初のエラーは次のとおりです(続くエラー4件は省いています)。

text
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 が消えます。

tsx
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} />
text
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つの画面に置き、猫とごはんを選びました。

text
# 最初の表示: 選んだ猫:なし/ごはん:なし
# 猫で「みけ」を選んだ後: 選んだ猫:みけ(5歳)/ごはん:なし
# ごはんで「ちゅーる」を選んだ後: 選んだ猫:みけ(5歳)/ごはん:ちゅーる(300円)
# 猫を「選んでください」に戻した後: 選んだ猫:なし/ごはん:ちゅーる(300円)

同じ Select が、猫では Cat を、ごはんでは Food を返しています。cat.age や food.price のように、それぞれの型のプロパティをそのまま表示に使えました。「選んでください」に戻すと、猫の表示が「なし」に戻りました(onChange に null が渡ったためです)。

検証環境のChromiumで、猫の一覧、ごはんの一覧、「ごはんはまだありません」の行の下に、2つの選択欄があり、猫で「みけ」、ごはんで「ちゅーる」を選んだ画面。下に「選んだ猫:みけ(5歳)/ごはん:ちゅーる(300円)」と表示されている
猫で「みけ」、ごはんで「ちゅーる」を選んだ後の画面
ポイント

props の型を ListProps<T> のようにし、コンポーネントを function List<T> で書きます。データの中身に関わる処理(key、表示する文字)は、getKey や renderItem のような関数で使う側に任せます。

型引数 T で、部品をデータの種類から切り離す

ジェネリクスで書いた List と Select は、渡した配列から T が決まり、renderItem や onChange の引数にもその型が付きます。検証では、ほかの型のプロパティを使ったり、別の型を受け取る関数を渡したりすると、型チェックで止まりました。ただし、エラーの出る場所がずれることや、空の配列で T が never になることがあるので、そのときは <Select<Cat>> のように型引数を書きます。

​Posted by 86