この記事は アソビューAdvent Calendar 2023 の6日目(B面)になります。
アソビューでフロントエンドエンジニアをしている白井です。 今回は文字入力時に候補検索し表示できるような Input コンポーネントを作ってみようと思います。
今のプロジェクトで使用している MUI の Autocomplete を使うことで簡単に実装できるのですが、オプションが多くて癖が強いことや、細かいことをやろうとしたりリクエストを間引いたりすると一手間必要になるので、そのあたりの解説をしていきます。
主に以下の3つのライブラリを使用して実装を進めていきます。
- データ取得:SWR
- バリデーション:React Hook Form
- コンポーネント:MUI Autocomplete
オートコンプリート付き Input の作成
まず、Autocomplete
コンポーネントを使って SearchableInput
という名前の独自のコンポーネントを作成してみます。
export type SearchableInputOption = { id: string; label: string; }; export type SearchableInputProps = { options: SearchableInputOption[]; onChange: (value: string) => void; onInputChange: (value: string) => void; onFocus?: (event: FocusEvent<HTMLDivElement, Element>) => void; loading?: boolean; value: string; } & Pick<InputProps, 'placeholder' | 'error' | 'helperText' | 'defaultValue'>; export const SearchableInput: FC<SearchableInputProps> = (props) => ( // ... (1) ... <Autocomplete<SearchableInputOption, false, true, true> autoComplete // 自由入力可能かどうか freeSolo // blur 時に選択するかどうか blurOnSelect // クリアを禁止するかどうか(Input の右端にクリアボタンが表示されなくなります) disableClearable // input value value={props.value} // 読み込み中かどうか loading={props.loading} // 読み込み中のテキスト loadingText={<CircularProgress size={16} />} // オートコンプリート候補リスト options={props.options.map((option) => option)} // ... (2) ... getOptionLabel={(value: SearchableInputOption | string) => typeof value === 'string' ? value : value?.label } // オートコンプリートの候補選択時 onChange={(_event, value) => { props.onChange(typeof value === 'string' ? value : value?.label ?? ''); }} // Input の内容変更時 onInputChange={(_event, value, reason) => { // ...(3)... if (reason !== 'input') return; props.onInputChange(value); }} // Input フォーカス時 onFocus={(e) => { if (props.value) return; props.onFocus?.(e); }} // Input 要素 renderInput={(params) => ( <Input {...params} helperText={props.helperText} error={props.error} placeholder={props.placeholder} /> )} /> );
このコンポーネントは入力値 value を元に options
のリストから部分一致で検索をかけ、フィルタリングして候補を表示します。
主なプロパティはコメントの通りですが、番号を振ってある箇所だけ別途説明します。
(1) <Autocomplete<SearchableInputOption, false, true, true>
コンポーネントの Generics で下記の指定を行います。Autocomplete
はオプションによって使える props が変わるため、ここで指定することで余計な props をガードできたり、コールバック引数の型を補完してくれます。4つの Generics があり、それぞれ以下になります。
Option
の型- 複数選択かどうか
- Input をクリアできるかどうか
- 自由入力可能かどうか
Autocomplete<T, Multiple extends boolean | undefined = undefined, DisableClearable extends boolean | undefined = undefined, FreeSolo extends boolean | undefined = undefined>(props: AutocompleteProps<T, Multiple, DisableClearable, FreeSolo>)
こちらは用途に合わせて変更してみてください(Autocomplete
に渡す props も若干変更になります)
(2) getOptionLabel(option)
getOptionLabel は option
の引数からラベル文字列を取得するためのものですが、freesolo
で自由入力ができる場合に、入力値の string が引数に入る可能性があります。しかし、引数の型はドキュメントにもある通り T
(ここでは SearchableInputOption
)となっているため、そのままだと string
を検知できません。
ドキュメントに則って、
(option) => option.label ?? option
でもよいかもしれませんが、型的にはありえない分岐となるため、今回の実装では手動で引数の型を書き換えました(こちらの Issue にもありますが、React Hook Form と Autocomplete
を組み合わせたときに他にも色々難があるようです…)。
(3)onInputChange
の reason !== 'input'
reason はドキュメントの
reason
Can be:"input"
(user input),"reset"
(programmatic change),"clear"
.
とある通り、どの方法で Input の中身が変化したのか検出することができます。今回はユーザーの直接入力以外は props.onInputChange
を実行したくなかったため、分岐を追加しています。
これがない場合、たとえばオートコンプリートからの候補選択時に Input の中身が変わったときにも props.onInputChange
が実行されます。
API から取得した情報を元に候補を作成
SearchableInput
で props.options
の中身を候補として表示する Input は作れましたが、API のデータから動的に検索することができません。
先ほど作成した SearchableInput
を継承し、「果物名を検索できるオートコンプリート付き検索 Input」を作成してみます。
type SearchableInputFruitNameForm = { fruitName?: string; }; type SearchableInputFruitNameProps = { value?: string; }; export const SearchableInputFruitsName: FC<SearchableInputFruitNameProps> = ({ value }) => { // ...(1)... const { formState: { errors }, setValue, } = useFormContext<SearchableInputFruitNameForm>(); // 検索で使うための「果物の名前」入力値 const [fruitName, setFruitName] = useState(''); // API から取得したデータから作成する「果物選択肢」 const [options, setOptions] = useState<SearchableInputOption[]>([]); // ...(2)... const [debouncedFruitName] = useDebouncedValue(fruitName, 1000); // ...(3)... const { data: listFruits, mutate: mutateListFruits, isLoading: isLoadingListFruits, // useSWRCustom は useSWR をカスタムしたオリジナル hook です。 } = useSWRCustom<ListFruitsResponse, ListFruitsRequest>( `ListFruits`, { fruitName: debouncedFruitName, }, { revalidateOnMount: false, }, ); // ...(4)... const handleChange: SearchableInputProps['onChange'] = useCallback( (value) => { // 候補からの選択時に、React Hook Form に値を渡す setValue('fruitName', value); // 候補をリセット setOptions([]); }, [setValue], ); const handleInputChange: SearchableInputProps['onInputChange'] = useCallback( (value) => { // React Hook Form に入力値を渡す setValue('fruitName', value); // 「検索で使うための果物の名前」を更新 setFruitName(value); }, [setValue], ); const handleFocus: SearchableInputProps['onFocus'] = useCallback(async () => { // 検索 Input にフォーカスしたときはリクエストを実行 mutateListFruits(); }, [mutateListFruits]); useEffect(() => { // API からデータを取得したら Option を更新 const listFruitsOptions = listFruits?.fruits.map((fruits) => ({ id: fruits.id, label: fruits.fruitName, })) ?? []; setOptions(listFruitsOptions); }, [listFruits]); return ( <SearchableInput value={value ?? ''} placeholder="部分一致で検索可" error={!!errors.fruitName} helperText={errors.fruitName?.message} options={options} onChange={handleChange} onInputChange={handleInputChange} onFocus={handleFocus} loading={isLoadingListFruits} /> ); };
詳しい解説は後述しますが、Input の入力値を主に3つに分けて使っているところが肝になります。
fruitName
:検索する時に使う値。debouncedFruitName
:fruitName
を一定時間遅延させて取得する値。実際の検索のパラメータにセットするのはこの値。setValue('fruitName')
: React Hook Form で管理している、Input の入力値。
setValue()
で Input の value をセットし続け、検索をかけたい場合のみ setFruitName()
でセットし、
遅延して更新された debouncedFruitName
の変更を useSWRCustom
が検知し検索を実行するという流れになります。
(1) React Hook Form
ここでは親コンポーネントで FormProvider
にメソッドを渡し、useFormContext
でそのメソッドを受け取ります。
(詳しくは 公式ドキュメント を参照)
ここで型として設定している SearchableInputFruitNameForm
のスキーマを、親の React Hook Form のスキーマが持っていれば(つまり fruitName?: string | undefined;
を持っている)、この SearchableInputFruitsName
コンポーネントを使い回すことができるようになります。
(2) useDebouncedValue
Input 入力時に1文字ごとに入力した瞬間 API Request を投げてしまうと、リクエスト過多になってしまいます。そこで、今回はある一定時間入力が止まった時にリクエストするようにしています。
Mantine の useDebouncedValue を使用して、入力から1秒経過したら debouncedFruitName
に値をセット、その変化を useSWRCustom
が受け取ってリクエストを実行するようにしています。
(3) useSWRCustom
ここでは SWR の useSWR
を少しカスタマイズして使っており、引数が少し異なります。
- 第一引数:エンドポイント
- 第二引数:リクエストパラメータ。検索するための
fruitName
をセット。 - 第三引数:SWR Configuration。マウント時にはリクエストを飛ばさないように
revalidateOnMount
を設定(検索にフォーカスしたときに実行)
となっています。
第二引数のリクエストパラメータが変更された場合に API Request が実行されるような仕組みとなっており、元となる useSWR の第一引数の key
が変化した時にリクエストを投げているのとやっていることは同じです。
(4) handleChange
前述した通り「オートコンプリートの候補を選択したとき」のイベントハンドラになります。
今回は候補選択時に検索で使用するための文字列 fruitsName
は更新せず、検索を実行しないようにしています。
そのためここでは React Hook Form で管理している Input の値だけを更新するような形です。
フォームの実装
最後に、SearchableInputFruitsName
を使うために親のコンポーネントに React Hook Form で実装していきます。バリデーションには yup を使っていますが、ここでは詳しい解説は割愛します。
こちらは「フリーワード」と「果物名」の2つを検索するようなフォームの例です。
export const schema = yup.object({ freeWord: yup.string().max(50).label('フリーワード'), fruitName: yup.string().max(50).label('果物名'), }); // InferType で yup の schema から型情報を生成することができます export type ListBookingsForm = yup.InferType<typeof schema>; export const SearchForm: FC = () => { // yup と組み合わせるために拡張したものですが、useForm の戻り値と同じものです。 const formMethods = useInputForm<ListBookingsForm>(schema); return ( <FormProvider {...formMethods}> <FormItem label={'フリーワード'}> <Input {...formMethods.register('freeWord')} error={!!formMethods.errors.freeWord} helperText={formMethods.errors.freeWord?.message} /> </FormItem> <FormItem label={'果物名'}> <Controller name="fruitName" control={formMethods.control} render={({ field }) => <SearchableInputFruitsName value={field.value} />} /> </FormItem> </FormProvider> ); };
親に必要なフォームを定義したら、React Hook Form の Controller
を使って先ほどの SearchableInputFruitsName
を render するだけです。型と name 値さえあっていれば、他の箇所でも流用できます。
完成図
おわりに
今回はオートコンプリート付き検索の一例を紹介しました。要件によって実装方法は変わってくると思うので、実装する際の参考になれば幸いです。
アソビューではフロントエンドエンジニアとして一緒に働く仲間を募集しています。 興味がある方は、ぜひこちらのエンジニア採用ページをご覧ください。