MUIのコンポーネントをラップした単数選択・複数選択ができるSelectコンポーネントを作ってみた

# はじめに
皆さんはMUIを使ったことはありますか？既にスタイルが適用されたテキスト、ボタン、テーブルなどの様々なコンポーネントが提供されていて便利なライブラリーですよね。私の所属するプロジェクトではよくMUIのコンポーネントをラップしたコンポーネントを作成して、共通のスタイルやロジックを持たせています。
先日、こちらのコンポーネントを改修する機会がありました。改修内容は「単数選択用のSelectコンポーネントを複数選択もできるように対応する」というものでした。
今回はこちらの改修の際に悩んだポイントとそれを解決した方法をご紹介します。

# 前置き
元々のSelectコンポーネントは以下のような作りになっていました。propsで選択中の値（value）と選択肢（selectItemList）を受け取るシンプルなコンポーネントです。
※細かなスタイルやロジックは省略しています

<details open><summary>Selectコンポーネントのイメージ</summary>

```javascript
export interface SelectItem {
  value: string;
  label: string;
}

export const SampleSelect = ({
  label,
  value,
  selectItemList,
  onChange,
}: {
  label: string;
  value: string;
  selectItemList: SelectItem[];
  onChange: (value: string) => void;
}) => {
  const handleChange = (event: SelectChangeEvent) => {
    onChange(event.target.value);
  };

  return (
    <FormControl>
      <InputLabel>{label}</InputLabel>
      <Select
        sx={{ width: '360px' }}
        label={label}
        value={value}
        variant={'outlined'}
        onChange={handleChange}
      >
        {selectItemList.map((item: SelectItem) => {
          return (
            <MenuItem
              key={item.value}
              value={item.value}
            >
              {item.label}
            </MenuItem>
          );
        })}
      </Select>
    </FormControl>
  );
};
```

</details>

あくまでも一例ですがSelectコンポーネントは以下のような形で使用します。選択中の値については親コンポーネントのstateで保持しており、onChangeにはstateを更新する関数を渡します。

<details open><summary>Selectコンポーネントを呼び出す際のイメージ</summary>

```javascript
const list = [
  { value: '', label: '未選択' },
  { value: '選択肢1', label: '選択肢1' },
  { value: '選択肢2', label: '選択肢2' },
  { value: '選択肢3', label: '選択肢3' },
];

export const SamplePage = () => {
  const [selectedValue, setSelectedValue] = useState<string>('');

  return (
    <SampleSelect
      label={'単一選択'}
      value={selectedValue}
      selectItemList={list}
      onChange={setSelectedValue}
    />
  );
};
```

</details>

# 悩んだポイント
単数選択と複数選択を両立させるなら、まずはpropsの型を`value: string`から`value: string|string[]`に変えれば良いだろうと考えました。valueについてはこの書き方で不都合はありませんが、問題はonChange() の引数の型です。
valueの型に合わせて`onChange: (value: string|string[]) => void`と書くと呼び出し元で毎回以下のような制御が必要になってしまいます。SampleSelectコンポーネント内の都合で定義した型が呼び出し元のhandleChange関数の引数にまで影響していてちょっと嫌ですよね。

<details open><summary>propsの型を「onChange: (value: string|string[]) => void」とした場合</summary>

```javascript
export const TopPage = () => {
  const [selectedValue, setSelectedValue] = useState<string[]>([]);

  const handleChange = (value: string|string[]) => {
    setSelectedValue(value as string[])
  };

  return (
    <SampleSelect
      label={'複数選択'}
      value={selectedValue}
      selectItemList={list}
      onChange={handleChange}
    />
  );
};
```

</details>

上記の例を踏まえてonChange()の型は
・valueの型がstring：`onChange: (value: string) => void`
・valueの型がstring[]：`onChange: (value: string[]) => void`
となる方が呼び出し元にとっては都合が良いです。これを実現するために今回はジェネリクスを使用しました。

# ジェネリクスとは？
今回のように「型は指定したい...けど使う時まで型が決まらない」という時に使えるのがジェネリクスです。以下のコードの`T`の部分がジェネリクスになります。関数を定義した時点ではTの型は定まっていませんが、`log<string>('test')`のように呼び出すことでTがstring型と定まります。ちなみに`log('test')`のように`<T>`の部分を明示的に指定しなくても暗黙的に型が定まります。
ちなみにジェネリクスは関数に限らずクラスやコンポーネントでも使うことができます。

```javascript
const log = <T>(value: T) => {
  console.log(value);
};

log<number>(1234);    // valueの型はnumberになる
log<string>('test');  // valueの型はstringになる
log('test');          // valueの型はstringになる
```

# 改修後のコンポーネント
先ほどのジェネリクスを使って冒頭でご紹介したSampleSelectコンポーネントを改修したものがこちらになります。今回はextendsを使ってTの型がstringかstring[]になるように制限をかけています。単数選択・複数選択の判定を行うisMultipleを追加していますが、それ以外に大きな変更はありません。

<details open><summary>改修後のコンポーネント</summary>

```javascript
export interface SelectItem {
  value: string;
  label: string;
}

export const SampleSelect = <T extends string | string[]>({
  label,
  value,
  selectItemList,
  onChange,
}: {
  label: string;
  value: T;
  selectItemList: SelectItem[];
  onChange: (value: T) => void;
}) => {
  const isMultiple = useMemo(() => {
    return Array.isArray(value);
  }, [value]);

  const handleChange = (event: SelectChangeEvent<T>) => {
    onChange(event.target.value as T);
  };

  return (
    <FormControl>
      <InputLabel>{label}</InputLabel>
      <Select
        sx={{ width: '360px' }}
        label={label}
        value={value}
        variant={'outlined'}
        multiple={isMultiple}
        onChange={handleChange}
      >
        {selectItemList.map((item: SelectItem) => {
          return (
            <MenuItem
              key={item.value}
              value={item.value}
            >
              {item.label}
            </MenuItem>
          );
        })}
      </Select>
    </FormControl>
  );
};
```
</details>

呼び出し元では以下のように利用します。setSelectedValueとsetSelectedValueListそれぞれについてhandleChange関数を作成しても良いですが今回は省略しています。

<details open><summary>呼び出し元のイメージ</summary>

```javascript
const list = [
  { value: '', label: '未選択' },
  { value: '選択肢1', label: '選択肢1' },
  { value: '選択肢2', label: '選択肢2' },
  { value: '選択肢3', label: '選択肢3' },
];

export const SamplePage = () => {
  const [selectedValue, setSelectedValue] = useState<string>('');
  const [selectedValueList, setSelectedValueList] = useState<string[]>([]);

  return (
    <Stack gap={2}>
      <SampleSelect
        label={'単数選択'}
        value={selectedValue}
        selectItemList={list}
        onChange={setSelectedValue}
      />
      <SampleSelect
        label={'複数選択'}
        value={selectedValueList}
        selectItemList={list}
        onChange={setSelectedValueList}
      />
    </Stack>
  );
};
```

</details>

これで
・valueの型がstring：`onChange: (value: string) => void` → 単数選択
・valueの型がstring[]：`onChange: (value: string[]) => void` → 複数選択
の切り替えができるようになりました。

# まとめ
今回はジェネリクスを使って単数選択・複数選択ができるSelectコンポーネントを作成しました。
要望をもらった時には少し悩みましたが、MUIのSelectコンポーネントを読み解くと元からこの機能が提供されていたので意外と簡単に実現することができました。
皆さんも同じような状況で悩んだ時にはジェネリクスが使えないか試していただければと思います。