6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

前置き

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

Selectコンポーネントのイメージ
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>
  );
};

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

Selectコンポーネントを呼び出す際のイメージ
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}
    />
  );
};

悩んだポイント

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

propsの型を「onChange: (value: string|string[]) => void」とした場合
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}
    />
  );
};

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

ジェネリクスとは?

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

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を追加していますが、それ以外に大きな変更はありません。

改修後のコンポーネント
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>
  );
};

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

呼び出し元のイメージ
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>
  );
};

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

まとめ

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

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?