この記事は約 4 分で読めます。
筆者プロフィール: ソフトウェアエンジニア。「知った気にならない。いつまでも学び続ける」を信条に、業務と個人開発の両輪で技術を磨いています。AI 駆動開発で複数の個人開発アプリを構築・運用中。
👉 ポートフォリオ: 筆者ホームページ
「プロジェクト選択 (1000 件)」を通常の Select で実装すると地獄。選択肢の件数に応じた UI 選択 で UX が決まります。本記事では、運用中の SaaS 「たすきば Knowledge Relay」 で採用している Select vs SearchableSelect の使い分けルール (KDD §5.8) を整理します。
サービスの機能紹介・画面イメージ・コンセプトは公式プロダクトページをご覧ください。
👉 たすきば Knowledge Relay — 公式プロダクトページ
選択肢の件数別 UI
業務 SaaS の選択肢項目:
| 選択肢 | 件数 |
|---|---|
| プロジェクトロール | 4 件 (PM / TL / member / viewer) |
| リスク種別 | 4 件 |
| 担当者 | 5〜200 件 |
| 顧客 | 10〜500 件 |
| 紐付けプロジェクト | 10〜1000 件 |
件数によって、適切な UI が違います。
1. 使い分けの基準
| 基準 | Select | SearchableSelect |
|---|---|---|
| 選択肢の件数 | 〜10 件 | 10+ 件 |
| 件数が固定 | ✓ | △ |
| 件数が動的に増える | × | ✓ |
| 検索が便利か | 不要 | 必要 |
| キーボード操作 | ↑↓ で選択 | 入力 + ↑↓ |
「10 件」が暗黙の閾値。
2. Select (通常のドロップダウン)
少数の固定選択肢に使う。
<Select value={role} onValueChange={setRole}>
<SelectTrigger>
<SelectValue placeholder="ロールを選択" />
</SelectTrigger>
<SelectContent>
<SelectItem value="PM">PM</SelectItem>
<SelectItem value="TL">TL</SelectItem>
<SelectItem value="member">member</SelectItem>
<SelectItem value="viewer">viewer</SelectItem>
</SelectContent>
</Select>
| 特徴 | 内容 |
|---|---|
| 全選択肢表示 | 一度に見える |
| スクロール | 少しならOK |
| 検索 | なし |
3. SearchableSelect (検索可能ドロップダウン)
多数の動的な選択肢に使う。
<SearchableSelect
value={projectId}
onChange={setProjectId}
options={projects.map(p => ({ value: p.id, label: p.name }))}
placeholder="プロジェクトを検索..."
emptyMessage="該当するプロジェクトがありません"
/>
| 特徴 | 内容 |
|---|---|
| 入力で絞り込み | 候補がフィルタされる |
| スクロール + 検索 | 両方で見つけられる |
| 性能 | 件数増加に強い |
4. Base UI Combobox の罠
SearchableSelect を Base UI の Combobox で実装すると、object 形式の items で罠を踏みます。
// ✗ NG: items が object 配列のとき、value 一致でフィルタされない
<Combobox.Items items={[
{ value: 'A', label: 'Alpha' },
{ value: 'B', label: 'Beta' },
]} />
object の場合、filterFunction で 明示的に value / label をマッチさせる必要 があります。
// ✓ OK: filterFunction を明示
<Combobox.Items
items={options}
filterFunction={(item, query) =>
item.label.toLowerCase().includes(query.toLowerCase())
}
/>
これをハマって 2 時間溶かしたケースが KDD に記録 されています。
5. value/label が unique でない罠
ユーザ名のように label が重複する可能性がある選択肢:
[山田 太郎 (admin@a.com)]
[山田 太郎 (tanaka@b.com)]
value は user.id で unique なので OK。ただし、label を表示するときに どちらかの情報 (例: email) を併記しないと、ユーザが識別できません。
options={users.map(u => ({
value: u.id,
label: `${u.name} (${u.email})`, // 識別情報を併記
}))}
細かいが、UI 設計で重要なポイント。
6. サーバ側でフィルタするパターン
選択肢が 1000 件超 になると、クライアントに全件送るとパフォーマンスが悪い。
<SearchableSelectServerFilter
fetchOptions={async (query) => {
const res = await fetch(`/api/projects?q=${encodeURIComponent(query)}&limit=20`);
return res.json();
}}
value={projectId}
onChange={setProjectId}
/>
| 仕様 | 内容 |
|---|---|
| 入力ごと | API を叩く (debounce 300ms) |
| 返却件数 | 最大 20 件 |
| 性能 | 件数に関係なく軽量 |
7. API レスポンスの形式
{
"options": [
{ "value": "p-001", "label": "ECサイトリニューアル", "subText": "ACME 社 / 進行中" },
{ "value": "p-002", "label": "在庫管理システム", "subText": "XYZ 社 / 完了" }
],
"totalCount": 15,
"hasMore": false
}
| フィールド | 用途 |
|---|---|
subText |
追加情報を表示 |
totalCount |
全候補数 |
hasMore |
「他にも候補がある」を UI に表示 |
8. Label と Input のペア (htmlFor / id)
Select / SearchableSelect には、必ず <Label> を htmlFor 付きで関連付けます。
// ✗ NG: htmlFor / id がない
<Label>プロジェクト</Label>
<Select>...</Select>
// ✓ OK
<Label htmlFor="project-select">プロジェクト</Label>
<Select id="project-select">...</Select>
| 効果 | 内容 |
|---|---|
| アクセシビリティ | スクリーンリーダ対応 |
| クリック範囲拡大 | Label クリックで Select にフォーカス |
| E2E セレクタ | ラベル経由のセレクタが使える |
KDD §5.10.1.5 に記録: 「Label と Input は htmlFor/id ペア必須」。
9. プレースホルダのデザイン
プレースホルダの文言で、ユーザに何をするかを伝えます。
| 文言 | 印象 |
|---|---|
| 「選択」 | 何を選ぶか不明 |
| 「プロジェクトを選択」 | 何を選ぶか明示 |
| 「プロジェクトを選択 (例: ECサイト)」 | 例示で具体的 |
業務 SaaS では、3 番目が最も親切。
SearchableSelect の場合は「プロジェクトを検索...」(入力できることが分かる)。
おわりに
| UI | 選択肢件数 | 用途 |
|---|---|---|
| Select | 〜10 件 | 固定選択肢 (ロール / 種別 等) |
| SearchableSelect | 10〜1000 件 | 動的選択肢 (担当者 / 顧客 等) |
| SearchableSelectServerFilter | 1000+ 件 | 大量データ (タグ / プロジェクト 等) |
件数に応じた UI 選択が、UX を決めます。横展開ルールを文書化することで、新規実装でも統一感を保てます。
本記事の使い分けは、運用中の SaaS 「たすきば Knowledge Relay」 で実装しています。
👉 たすきば Knowledge Relay — 公式プロダクトページ