react-select の選択肢に画像を表示したりカスタマイズするレシピ集
こんにちは。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
コンポーネントを作りましょう。
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
をページコンポーネントから呼び出します。
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も用意します。
.userSelect {
margin: 10rem;
}
.selected {
padding: 1rem;
}
これで起動します。
yarn dev
次のような画面にアクセスでき、選択肢が表示されればOKです。
最小限の構成でもいい感じに動いてくれていますね。ここからさらにカスタマイズしていきましょう。
選択肢にアバター画像を表示する
思わせぶりなavatarUrl
をUser型へ仕込んでいました。これを使って、選択肢にアバター画像を表示してみましょう。react-select のSelect
コンポーネントはformatOptionLabel
を設定できます。ここにカスタマイズしたReactコンポーネントを渡すことで好みの見た目に変更できます。
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を追加してください。
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を書きます。以下余白を確保するサンプルです。ついでに選択肢の要素を中央になるように調整したりも入れてます。
.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
を指定すればすでに選択されたデータを消せます。
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}
/>
);
バツボタンが追加されました。バツボタンを押すと、選択が解除されます。コード的には、setUser
にnull
が渡されます。よって、選択されたユーザーがnull
になりえる実装としておけば、isClearable
を追加するだけで削除もできるようになります。以下、実際にバツボタンを押したときの様子です。
選択肢を検索できるようにする
たくさん選択肢があるときに、マウスでスクロールして探すのは大変です。名前をキーボードでタイプして検索できると便利ですね。Select
コンポーネントでisSearchable
を有効にするとこれまたすぐに利用できるようになります。ありがてぇ〜。
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
でも探せるようにしましょう。
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
を指定します。
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
を指定します。
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はありませんが、components
propsを調整することでこのあたりの細かい制御も可能です。セパレーターと言っているのはコレのことです。
IndicatorSeparator
をnullとすることで消せます。
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の配列を受け取れるように変更しなくてはなりません。初期値も含めて、全体的にテコ入れが必要です。
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}
/>
);
};
あわせて呼び出し側も修正します。
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 の魅力の一つだと思います。
ソースコード
Discussion