フューチャー技術ブログ

Reactで決められた候補から選択させるコンボボックスを実装する(サーバーアクセスつき)

技術コンサルを行っているお客様から質問があったのですが、なかなかドンピシャな情報がなく、試行錯誤したのでその検討結果をまとめてみました。

実現したいのは以下のようなコンボボックスです

  • IDのリストをユーザーに選択させる
  • キーボードで絞り込みをかけられる
  • IDのリストは大量にあり、全件をあらかじめサーバーに問い合わせて取得するのはパフォーマンスが良くない

コンボボックスと同義語的にも使われるドロップボックスというコンポーネントがあります。こちらはメニューからの選択のみで、キーボードの絞り込みがないもので、コンボボックスはテキストボックスがある、という想定でここで話をすすめます。

IDの候補が有限個であれば、標準のHTMLでも簡単に実現できます。

<label for="ice-cream-choice">Choose a flavor:</label>
<input list="ice-cream-flavors" id="ice-cream-choice" name="ice-cream-choice">

<datalist id="ice-cream-flavors">
<option value="Chocolate">
<option value="Coconut">
<option value="Mint">
<option value="Strawberry">
<option value="Vanilla">
</datalist>
スクリーンショット_2022-12-02_17.01.46.png

コンボボックスを実現するコンポーネント集もありますが、動的にサーバーに候補を問い合わせるのをやりやすくしてくれる機能が組み込まれていないものも多いです。一部、MaterialUIではAutocompleteコンポーネントがあって実現はできそうですし、ネットワーク越しに非同期に候補を絞るのも対応してそうですが、この1機能のためにUI部品を乗り換えるわけにもいかないので、素のReactで実現してみます。

入力の状態を検討する

Reactでコーディングする場合は、どのような状態を管理するか、誰がその状態を更新するかを検討します。次のような操作が考えられます。

  • テキスト入力した値がコンボボックスに表示される
  • テキスト入力で絞り込みを行い、選択候補をリストに出す
  • テキスト入力で候補と正確に一致するテキストが入力されたので選択する
  • テキスト入力で候補が1つになるまで絞り込みを行ったのでそれを選択する
  • 絞り込まれた候補からマウスで選択する
  • 再選択するためにキーボードでテキストを削除し、再度絞り込みを行う

いろいろ試行錯誤した結果、以下のようになりました。

combobox.png
  • 今回は、validな要素以外を選択要素としないため、最終的な選択値を1つ状態として持ちます。今回のサンプルはuseState()で作っていますが、React Hook Formとかでも良いです。
  • 最終的な値だけの場合、ユーザーが絞り込みを行うための中間状態が表現できません。uncontrolled formならば不要ですが、UI部品によっては選択候補が更新されるとリセットされてしまって都合が良くないことが考えられるため、表示用の値も状態として持ちます
  • 表示用の入力値をそのままuseSWR()のサーバー呼び出しの更新キーとして使ってもいいのですが、マウスで候補から選択した場合など、サーバーアクセスしなくてもいい場合にもサーバーリクエストが走ってしまうと困るので、サーバー検索を行うキーを別途保持します。
  • 検索候補が1つに絞れたら、そこで選択終了としたいのですが、そうした場合、キーボードのbackspaceで最後のテキストを消して再絞り込みしようとしても再び候補が1つで強制的に選択されて末尾のテキストが消せない、ということがあったので、選択終了条件に、最後のキーボード入力がbackspace等のテキスト消去ではない、という条件が必要でした。そのため、1つ前の表示値も取っておく必要があるため、useRefで保持します。

ユーザーに「あれ?」と思わせないコンボボックスを作るにはこれだけの状態が必要かと思われます。テキストとマウスと両方扱う必要があるので面倒ですが仕方がありません。

実装してみる

1000以下の素数で好きな値を選択するコンボボックスを作ってみます。Vite.jsで作ったプロジェクトをベースにしています。

サーバーにアクセスして値を取ってきて候補を出しますが、このためだけにサーバーを実装するのは面倒なのでMock Service WorkerでAPIサーバーの代替をします。

mock.ts
import { setupWorker, rest } from 'msw'
import getPrimes from 'get-primes';

function sleep(msec: number) {
return new Promise(resolve => {
window.setTimeout(() => { resolve(0); }, msec);
});
}

export const worker = setupWorker(
rest.get('/primes', async (req, res, ctx) => {
// prefix=で渡された文字で始まる素数をリストで返す
const prefix = req.url.searchParams.get('prefix') || ''
await sleep(1000); // サーバーの遅さをシミュレートするためのウェイト
const result: number[] = [];
for (const v of getPrimes(1000)) {
if (String(v).startsWith(prefix)) {
result.push(v);
if (result.length === 10) { // 最大10候補だけ返す
break;
}
}
}
return res(ctx.json({result})
}),
);

サーバーにアクセスしてくるhookを実装します。

use-completionlist.ts
import useSWR from 'swr';

async function fetcher(url: string) {
const res = await fetch(url);
const { result } = await res.json() as {result: number[]};
return result;
};

export function useCompletionList(prefix: string) {
const { data, error } = useSWR(
`/primes?prefix=${encodeURIComponent(prefix)}`,
fetcher
);
return { data: data ? data : [], error, loading: !data && !error };
}

最後にコンボボックスと必要なstate群を実装します。

App.tsx
import { useState, useCallback, useEffect, useRef } from 'react';
import { useCompletionList } from 'use-completionlist';

function App() {
const [ userInput, setUserInput ] = useState(''); // 表示用の値
const lastUserInput = useRef(''); // 1つ前の表示用の値
const [ searchKey, setSearchKey ] = useState(''); // 検索キー
const [ confirmedValue, setConfirmedValue ] = useState(''); // 選択された値
const { data, error, loading } = useCompletionList(searchKey); // 候補リスト

useEffect(() => {
if (data.length === 1 && userInput.length > lastUserInput.current.length) {
// キーボード選択用: もし、検索候補が1件しかない場合は先頭一致でその要素が選択されたものとする。表示も更新する。
setUserInput(String(data[0]));
lastUserInput.current = String(data[0]);
setConfirmedValue(String(data[0]));
} else {
// キーボード選択用: 正確に一致するものが候補にあればその要素が選択されたものとする
const found = data.find(v => String(v) === userInput);
if (found !== undefined) {
setConfirmedValue(String(found));
}
}
}, [userInput, data])

const onChange = useCallback(function onChange(e: React.ChangeEvent<HTMLInputElement>) {
lastUserInput.current = userInput; // 最後の入力をとっておく
setUserInput(e.target.value);
if (data.some(v => String(v) === e.target.value)) {
// マウス選択用: 候補のリストを検索して正確にマッチするものがあったら確定(検索はしない)
setConfirmedValue(e.target.value);
} else {
// キーボード選択用: 正確にマッチするものがなければサーバーに問い合わせて候補リストを最新化
setSearchKey(e.target.value);
}
}, [data]);

return (
<div className="App">
{ error ? <div>エラー: {String(error)}</div> : undefined }
<div>選択された素数: {confirmedValue}</div>
<label>素数選択: <input type="text" name="example" list="exampleList" value={userInput} onChange={onChange}/></label>
{ loading ? "🌀" : (
<datalist id="exampleList">
{ data.map(value => (<option key={value} value={value} />))}
</datalist>
)}
</div>
);
}

export default App;

うまく実装できました。

スクリーンショット_2022-12-02_17.56.53.png

一箇所にまとめて実装しましたが、data, loading, onChange, confirmedValue, userInput, setUserInputを返すカスタムフックを作っておくと再利用が効きそうです。

まとめ

いろんなライブラリとの組み合わせを考えると、状態の変更のライフサイクルをきちんとコントロールできるようにしておくことが大切です。そのため、キーボードの絞り込み機能つきのコンボボックスを手作りしてみました。Reactでもなんでも、サーバーからのレスポンスを含めて、状態管理が複雑になってくると結構ややこしくなって、バグっぽい動きになったりしがちです。

カスタマイズとしては、サーバーアクセスをuseDebounceを使って絞るというのはやってもいいかと思います。あとは候補が選択されたときに候補欄を非表示にするとかですかね。確定した値と表示が一致していたらdatalistごと削除とかでいけるかと思います。