MUI Autocomplete で API のデータから検索候補を表示する Input を作ってみる

この記事は アソビューAdvent Calendar 2023 の6日目(B面)になります。

アソビューでフロントエンドエンジニアをしている白井です。 今回は文字入力時に候補検索し表示できるような Input コンポーネントを作ってみようと思います。

果物検索補完付き Input

今のプロジェクトで使用している MUI の Autocomplete を使うことで簡単に実装できるのですが、オプションが多くて癖が強いことや、細かいことをやろうとしたりリクエストを間引いたりすると一手間必要になるので、そのあたりの解説をしていきます。

主に以下の3つのライブラリを使用して実装を進めていきます。

オートコンプリート付き 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)

getOptionLabeloption の引数からラベル文字列を取得するためのものですが、freesolo で自由入力ができる場合に、入力値の string が引数に入る可能性があります。しかし、引数の型はドキュメントにもある通り T(ここでは SearchableInputOption)となっているため、そのままだと string を検知できません。

ドキュメントに則って、

(option) => option.label ?? option

でもよいかもしれませんが、型的にはありえない分岐となるため、今回の実装では手動で引数の型を書き換えました(こちらの Issue にもありますが、React Hook Form と Autocomplete を組み合わせたときに他にも色々難があるようです…)。

(3)onInputChangereason !== 'input'

reason はドキュメント

reason Can be: "input" (user input), "reset" (programmatic change), "clear".

とある通り、どの方法で Input の中身が変化したのか検出することができます。今回はユーザーの直接入力以外は props.onInputChange を実行したくなかったため、分岐を追加しています。

これがない場合、たとえばオートコンプリートからの候補選択時に Input の中身が変わったときにも props.onInputChange が実行されます。

API から取得した情報を元に候補を作成

SearchableInputprops.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 :検索する時に使う値。
  • debouncedFruitNamefruitName を一定時間遅延させて取得する値。実際の検索のパラメータにセットするのはこの値。
  • 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

ここでは SWRuseSWR を少しカスタマイズして使っており、引数が少し異なります。

  • 第一引数:エンドポイント
  • 第二引数:リクエストパラメータ。検索するための 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 値さえあっていれば、他の箇所でも流用できます。

完成図

文字を入力していると、一定時間はリクエストを飛ばしません。

おわりに

今回はオートコンプリート付き検索の一例を紹介しました。要件によって実装方法は変わってくると思うので、実装する際の参考になれば幸いです。

アソビューではフロントエンドエンジニアとして一緒に働く仲間を募集しています。 興味がある方は、ぜひこちらのエンジニア採用ページをご覧ください。

www.asoview.com