🌊

react-select の選択肢に画像を表示したりカスタマイズするレシピ集

2022/07/08に公開

こんにちは。react-selectを使ってセレクトボックスを実装する機会があったので、この記事に使い方を記録します。早速ベースを用意しましょう。Next.jsを使います。

yarn create next-app react-select-cook --ts

cd react-select-cook

# CSS Modules でsassを使いたいので
yarn add -D sass

# 本命
yarn add react-select

最小限の表示

まずは react-select を使って選択肢を表示し、選択されたものを画面に表示してみます。ここでは、架空のユーザーを用意し、固定データとして選択肢を表示します。UserSelectコンポーネントを作りましょう。

src/components/UserSelect.tsx
import React, { useMemo } from "react";
import Select from "react-select";

// コンポーネントのProps。初期値と選択時の関数を渡します
export type UseSelectProps = {
  selected: User | null;
  setUser: (user: User | null) => void;
};

// APIのレスポンスなどを想定した、User型です
export type User = {
  id: number;
  name: string;
  displayName: string;
  avatarUrl: string;
};

// 選択候補となる固定データです。実際にはAPIレスポンスなどから取得するはずです。
const sampleUsers: User[] = [
  {
    id: 1,
    name: "john_doe",
    displayName: "John Doe",
    avatarUrl: "https://avatars0.githubusercontent.com/u/1?v=4",
  },
  {
    id: 2,
    name: "alice_smith",
    avatarUrl: "https://avatars0.githubusercontent.com/u/2?v=4",
    displayName: "Alice Smith",
  },
];

// 選択肢を react-select にわたすための型。labelとvalueがあればよさそうですが、
// User型との相互変換のためにnameとavatarUrlも用意します。
type UserOption = {
  label: string;
  value: number;
  name: string;
  avatarUrl: string;
};

// Option型をUser型に変換します。react-select から渡されたデータをアプリで扱うために使います
function convertToUser(args: UserOption | null): User | null {
  if (!args) return null;
  return {
    id: args.value,
    name: args.name,
    displayName: args.label,
    avatarUrl: args.avatarUrl,
  };
}

// User型をOption型に変換します。react-select へデータを渡すときに変換します
function convertToOption(user: User): UserOption {
  return {
    label: user.displayName,
    value: user.id,
    name: user.name,
    avatarUrl: user.avatarUrl,
  };
}

// react-select を使った、ユーザー選択コンポーネント
export const UserSelect: React.FC<UseSelectProps> = ({ setUser, selected }) => {
  const value = useMemo(
    () => (selected ? convertToOption(selected) : null),
    [selected]
  );

  function onChange(newUser: UserOption | null) {
    setUser(convertToUser(newUser));
  }

  return (
    <Select
      instanceId="userSelect"
      value={value} // 選択中の値
      onChange={onChange} // 選択されたときにはしる処理
      options={sampleUsers.map(convertToOption)} // 選択肢
    />
  );
};

UserSelect をページコンポーネントから呼び出します。

src/pages/index.tsx
import type { NextPage } from "next";
import { useState } from "react";
import { User, UserSelect } from "../components/UserSelect";

import styles from "./index.module.scss";

const Home: NextPage = () => {
  
  // 選択中のユーザーを管理するためのstate
  // 今回のサンプルでは初期値をベタ書きしていますが、実際にはAPIなどから取得したUser型の値が初期値になるはずです
  const [user, setUser] = useState<User | null>({
    id: 2,
    name: "alice_smith",
    avatarUrl: "https://avatars0.githubusercontent.com/u/2?v=4",
    displayName: "Alice Smith",
  });
  return (
    <div className={styles.userSelect}>
      <div className={styles.selected}>selected: {user?.displayName}</div>
      <UserSelect setUser={setUser} selected={user} />
    </div>
  );
};

export default Home;

あとは、ちょっとした見た目の変更ですが、scssも用意します。

src/pages/index.module.scss
.userSelect {
    margin: 10rem;
}
.selected {
    padding: 1rem;
}

これで起動します。

yarn dev

次のような画面にアクセスでき、選択肢が表示されればOKです。

最小限の構成でもいい感じに動いてくれていますね。ここからさらにカスタマイズしていきましょう。

選択肢にアバター画像を表示する

思わせぶりなavatarUrlをUser型へ仕込んでいました。これを使って、選択肢にアバター画像を表示してみましょう。react-select のSelectコンポーネントはformatOptionLabelを設定できます。ここにカスタマイズしたReactコンポーネントを渡すことで好みの見た目に変更できます。

UserSelect.tsxに追記してください。

src/components/UserSelect.tsx
...(省略)

+const FormatOptionLabel = memo(({ option }: { option: UserOption }) => (
+  <div className={styles.userSelect}>
+    <img
+      src={option.avatarUrl}
+      alt={option.name}
+      className={styles.avatar}
+      width={40}
+      height={40}
+      referrerPolicy="no-referrer"
+    />
+    <div>{option.label}</div>
+  </div>
+));

export const UserSelect: React.FC<UseSelectProps> = ({ setUser, selected }) => {
  const value = useMemo(
    () => (selected ? convertToOption(selected) : null),
    [selected]
  );

  function onChange(newUser: UserOption | null) {
    setUser(convertToUser(newUser));
  }

  return (
    <Select
      instanceId="userSelect"
      value={value}
      onChange={onChange}
      options={sampleUsers.map(convertToOption)}
+      formatOptionLabel={(option) => (
+        <FormatOptionLabel option={option} />
+      )}
    />
  );
};

formatOptionLabelは関数を要求します。optionを受け取り、それを使って見た目を整えた結果のReactComponentを返す関数です。ここではFormatOptionLabelを返すようにしました。FormatOptionLabelでは、avatarUrlからアバターを生成し、名前と一緒に表示しています。以下のようになります。

無事、アバター画像が表示されました。ここからさらにもう一声、余白を調整したいです。そのためには、react-select が自動展開する要素に対してpaddingを設定します。

react-select 自体にCSSをあてる

選択肢の余白を調整したいので、react-selectが展開する要素にCSSを当てます。Selectコンポーネントが展開する要素のクラス名には、接頭辞をつけることができます。これはアプリ内で定義した他のクラス名と衝突しないようにする仕組みです。以下のようにSelectコンポーネントへのpropsを追加してください。

src/components/UserSelect.tsx
  return (
    <Select
      instanceId="userSelect"
      value={value}
      onChange={onChange}
      options={sampleUsers.map(convertToOption)}
      formatOptionLabel={(option) => (
        <FormatOptionLabel selected={value} option={option} />
      )}
+      className={styles.container}
+      classNamePrefix="rs"
    />
  );

これでrsという接頭辞がクラス名に付与されます。

あとは、react-selectのStyleに関するドキュメントを見ながらCSSを書きます。以下余白を確保するサンプルです。ついでに選択肢の要素を中央になるように調整したりも入れてます。

src/components/UserSelect.module.scss
.container {
  font-size: 20px;
  :global {
    // 選択された要素が入る領域に対して、余白を確保
    .rs__value-container {
      padding: 0.7rem;
    }
    // 選択された要素をflexの入れ子にして、中央に揃えられるように
    .rs__single-value {
      display: flex;
      align-items: center;
    }
    // 選択肢の要素をflexの入れ子にして、中央に揃えられるように
    .rs__option {
      display: flex;
      align-items: center;
    }
  }
}

Selectが展開した要素に対してCSSをあてることができました。

選択を解除できるようにする

アプリにこのUserSelectを組み込む場合、「ユーザーが選択されててもいいし、何も選択されていなくてもよい」という仕様はあり得ると思います。isClearableを指定すればすでに選択されたデータを消せます。

src/components/UserSelect.module.scss
  return (
    <Select
      instanceId="userSelect"
      value={value}
      onChange={onChange}
      options={sampleUsers.map(convertToOption)}
      formatOptionLabel={(option) => (
        <FormatOptionLabel selected={value} option={option} />
      )}
      className={styles.container}
      classNamePrefix="rs"
+      isClearable={true}
    />
  );

バツボタンが追加されました。バツボタンを押すと、選択が解除されます。コード的には、setUsernullが渡されます。よって、選択されたユーザーがnullになりえる実装としておけば、isClearableを追加するだけで削除もできるようになります。以下、実際にバツボタンを押したときの様子です。

選択肢を検索できるようにする

たくさん選択肢があるときに、マウスでスクロールして探すのは大変です。名前をキーボードでタイプして検索できると便利ですね。SelectコンポーネントでisSearchableを有効にするとこれまたすぐに利用できるようになります。ありがてぇ〜。

src/components/UserSelect.tsx
  return (
    <Select
      instanceId="userSelect"
      value={value}
      onChange={onChange}
      options={sampleUsers.map(convertToOption)}
      formatOptionLabel={(option) => (
        <FormatOptionLabel selected={value} option={option} />
      )}
      className={styles.container}
      classNamePrefix="rs"
      isClearable={true}
+      isSearchable={true}
    />

表示名でも、システム用のローマ字でも検索できるように

日本人の場合、表示名は漢字で、システム用のショートネームはローマ字で設定する状況もよくあります。User型のJSONで書くと次のようなイメージ。

  {
    id: 3,
    name: "shohei_kawaguchi",
    avatarUrl: "https://avatars0.githubusercontent.com/u/3?v=4",
    displayName: "川口昌平",
  }

react-select デフォルトでは Option として渡すデータのlabel(displayName)で検索する動きですが、拡張できます。nameでも探せるようにしましょう。

src/components/UserSelect.tsx
  return (
    <Select
      instanceId="userSelect"
      value={value}
      onChange={onChange}
      options={sampleUsers.map(convertToOption)}
      formatOptionLabel={(option) => (
        <FormatOptionLabel selected={value} option={option} />
      )}
      className={styles.container}
      classNamePrefix="rs"
      isClearable={true}
      isSearchable={true}
+      getOptionLabel={(option) => option.label + option.name}
    />


↑「川」でヒットする様子


↑「kawa」でもヒットする様子

選択肢がない、見つからないときのメッセージを変更

noOptionsMessageを指定します。

src/components/UserSelect.tsx
  return (
    <Select
      instanceId="userSelect"
      value={value}
      onChange={onChange}
      options={sampleUsers.map(convertToOption)}
      formatOptionLabel={(option) => <FormatOptionLabel option={option} />}
      className={styles.container}
      classNamePrefix="rs"
      isClearable={true}
      isSearchable={true}
      getOptionLabel={(option) => option.label + option.name}
+      noOptionsMessage={() => "ユーザーはいません"}
    />
  );

プレースホルダーを変更

placeholderを指定します。

src/components/UserSelect.tsx
  return (
    <Select
      instanceId="userSelect"
      value={value}
      onChange={onChange}
      options={sampleUsers.map(convertToOption)}
      formatOptionLabel={(option) => <FormatOptionLabel option={option} />}
      className={styles.container}
      classNamePrefix="rs"
      isClearable={true}
      isSearchable={true}
      getOptionLabel={(option) => option.label + option.name}
      noOptionsMessage={() => "ユーザーはいません"}
+      placeholder="メッセージを送るユーザーを選んでください"
    />
  );

セパレーターを消す

そのままズバリのpropsはありませんが、componentspropsを調整することでこのあたりの細かい制御も可能です。セパレーターと言っているのはコレのことです。

IndicatorSeparatorをnullとすることで消せます。

src/components/UserSelect.tsx
  return (
    <Select
      instanceId="userSelect"
      value={value}
      onChange={onChange}
      options={sampleUsers.map(convertToOption)}
      formatOptionLabel={(option) => <FormatOptionLabel option={option} />}
      className={styles.container}
      classNamePrefix="rs"
      isClearable={true}
      isSearchable={true}
      getOptionLabel={(option) => option.label + option.name}
      noOptionsMessage={() => "ユーザーはいません"}
      placeholder="メッセージを送るユーザーを選んでください"
+      components={{
+        IndicatorSeparator: () => null,
+      }}
    />
  );

ひとつだけ選ぶのではなく、複数選べる形式にする

ここまでひとつだけ選ぶ形式でしたが、複数選択させることもできます。ただし、コールバックなどもUserの配列を受け取れるように変更しなくてはなりません。初期値も含めて、全体的にテコ入れが必要です。

src/components/UserSelect.tsx
import React, { memo, useMemo } from "react";
import Select from "react-select";
import styles from "./UserSelect.module.scss";

-export type UseSelectProps = {
-  selected: User | null;
-  setUser: (user: User | null) => void;
-};
+export type UsersSelectProps = {
+  selected: User[];
+  setUsers: (users: User[]) => void;
+};

export type User = {
  id: number;
  name: string;
  displayName: string;
  avatarUrl: string;
};

const sampleUsers: User[] = [
  {
    id: 1,
    name: "john_doe",
    displayName: "John Doe",
    avatarUrl: "https://avatars0.githubusercontent.com/u/1?v=4",
  },
  {
    id: 2,
    name: "alice_smith",
    avatarUrl: "https://avatars0.githubusercontent.com/u/2?v=4",
    displayName: "Alice Smith",
  },
  {
    id: 3,
    name: "shohei_kawaguchi",
    avatarUrl: "https://avatars0.githubusercontent.com/u/3?v=4",
    displayName: "川口昌平",
  },
];

type UserOption = {
  label: string;
  value: number;
  name: string;
  avatarUrl: string;
};

-function convertToUser(args: UserOption | null): User | null {
+function convertToUser(args: UserOption): User {
  return {
    id: args.value,
    name: args.name,
    displayName: args.label,
    avatarUrl: args.avatarUrl,
  };
}

function convertToOption(user: User): UserOption {
  return {
    label: user.displayName,
    value: user.id,
    name: user.name,
    avatarUrl: user.avatarUrl,
  };
}

const FormatOptionLabel = memo(({ option }: { option: UserOption }) => (
  <div className={styles.userSelect}>
    <img
      src={option.avatarUrl}
      alt={option.name}
      className={styles.avatar}
      width={40}
      height={40}
      referrerPolicy="no-referrer"
    />
    <div>{option.label}</div>
  </div>
));

-export const UserSelect: React.FC<UseSelectProps> = ({ setUser, selected }) => {
-  const value = useMemo(
-    () => (selected ? convertToOption(selected) : null),
-    [selected]
-  );
-
-  function onChange(newUser: UserOption | null) {
-    setUser(convertToUser(newUser));
-  }
+export const MultiUserSelect: React.FC<UsersSelectProps> = ({
+  setUsers,
+  selected,
+}) => {
+  const value = useMemo(() => selected.map(convertToOption), [selected]);
+
+hn     function onChange(newUsers: readonly UserOption[]) {
+    setUsers(newUsers.map(convertToUser));
+  }

  return (
    <Select
-      instanceId="userSelect"
+      instanceId="usersSelect"
      value={value}
      onChange={onChange}
      options={sampleUsers.map(convertToOption)}
      formatOptionLabel={(option) => <FormatOptionLabel option={option} />}
      className={styles.container}
      classNamePrefix="rs"
      isClearable={true}
      isSearchable={true}
      getOptionLabel={(option) => option.label + option.name}
      noOptionsMessage={() => "ユーザーはいません"}
      placeholder="メッセージを送るユーザーを選んでください"
      components={{
        IndicatorSeparator: () => null,
      }}
+      isMulti={true}
    />
  );
};

あわせて呼び出し側も修正します。

src/pages/index.tsx

const Home: NextPage = () => {
-  const [user, setUser] = useState<User | null>({
-    id: 2,
-    name: "alice_smith",
-    avatarUrl: "https://avatars0.githubusercontent.com/u/2?v=4",
-    displayName: "Alice Smith",
-  });
+  const [users, setUsers] = useState<User[]>([]);
  return (
    <div className={styles.userSelect}>
-      <div className={styles.selected}>selected: {user?.displayName}</div>
-      <UserSelect setUser={setUser} selected={user} />
+      <div className={styles.selected}>
+        selected: {users.map((u) => u.displayName + ' ')}
+      </div>
+      <MultiUserSelect setUsers={setUsers} selected={users} />
    </div>
  );
};

次のような使い心地になります。

まとめ

react-select の使い方を紹介しました。選んでセットするだけのセレクトボックスですが、やり始めると考えることも多く大変ですよね。このライブラリは目的をある程度絞りつつも、かなり細かいところまでカスタマイズできるので便利です。今回はユーザーを選ぶようなユースケースでしたが、「すでにあるもののなかから選ぶ」という行為がアプリ内で頻出する場合は、ぜひ頼っていきたいところです。

完全に余談ですが、GitHub Copilot を試しに使っています。この記事を書くにあたりサンプルコードを用意していて、モックデータのアバター画像どこからとってこようかなーと考えていたところ、

{
    avatarUrl: "https://avatars0.githubusercontent.com/u/1?v=4",
}

このavatarUrlの値部分はGitHub Copilotがサジェストしてくれたものです。GitHubユーザーのアバター画像が取れるみたいですね。遠慮なく拝借しています。このように、「存在を知らないのでなかなか調べられない」ものをサジェストしてくれ、存在に気づけるというのも GitHub Copilot の魅力の一つだと思います。

ソースコード

https://github.com/cm-wada-yusuke/gql-nest-prisma-training/tree/main/react-select-cook

Discussion