📝

FormikとMUIでのオートコンプリートの実装方法

2023/06/23に公開

お仕事関係で表題のようなことをやりたかったけど、ネットをさがしても良いサンプルが見つけられず試行錯誤が必要だったので記事として公開しておきます。

Demo

まずはデモを示します。

東京都の郵便番号を入力してもらうフォームと仮定して、住所の文言をキーワードして Autocomplete コンポーネントを利用して候補を表示・選択できようにしています。 (郵便番号の一覧はすべてを入れると数が多すぎるので一部のみ抜粋しています)

Submit ボタンを押すと、Form の中身を JSON で出力します。

コード

コードは以下のようになりました。

import { SyntheticEvent, useMemo, useCallback, useState } from "react";
import "./styles.css";
import { useFormik } from "formik";
import { Autocomplete, TextField, debounce } from "@mui/material";
import { postalCodes, usePostalCode } from "./data";

type FormValues = {
  postalCode: string | null;
};

export default function App() {
  const [filterKeyword, setFilterKeyword] = useState<string>("");
  const [submitCode, setSubmitCode] = useState<string>("");

  // 候補の一覧を抽出するカスタムHook
  const { data: filteredPostalCodes } = usePostalCode(filterKeyword);

  const formik = useFormik<FormValues>({
    initialValues: {
      postalCode: null
    },
    onSubmit: (values) => {
      console.log(JSON.stringify(values));
      setSubmitCode(JSON.stringify(values));
    }
  });

  const debouncedSetter = useMemo(
    () => debounce((keyword: string) => setFilterKeyword(keyword), 500),
    []
  );

  return (
    <div className="App">
      <h1>Formik + MUI Autocomplete Demo</h1>
      <form onSubmit={formik.handleSubmit}>
        <label htmlFor="postalCode">Postal Code: </label>
        <Autocomplete
          value={formik.values.postalCode}
          onChange={(_: SyntheticEvent, newValue: string | null) =>
            formik.setFieldValue("postalCode", newValue)
          }
          options={filteredPostalCodes?.map((p) => p.postalCode) ?? []}
          onInputChange={(_, newInputValue) => debouncedSetter(newInputValue)}
          // filterOptions={(x) => x} // 自前で候補をフィルタするのでデフォルトのフィルタを無効化する
          getOptionLabel={(option: string) => {
            return (
              postalCodes.find((p) => p.postalCode === option)?.address ?? ""
            );
          }}
          renderInput={(params) => (
            <TextField {...params} placeholder="郵便番号" id="" />
          )}
        />
        <button type="submit">Submit</button>
      </form>
      <div>{submitCode && <>Submit code: {submitCode}</>}</div>
    </div>
  );
}

実装のポイント

いくつか実装のポイントがあったので、それらについて以下で簡単に解説します。

Autocomplete の value の型は Formik の項目の型と一致させる

Autocomplete で入力した値を Formik のフォームの入力として扱う場合、 それぞれの値の型は一致させたほうが扱いが楽になります。

サンプルのコードでは、 Formik の postalCode という項目に対して Autocomplete で入力させるようなフォームを作成しています。 このとき、 Formik の values.postalCode の型と Autocomplete の value の型はともに string となるように実装しています。

type FormValues = {
  // Formik の value として管理する値は (string)
  postalCode: string | null;
};

// ... (snip) ...

	<Autocomplete
	  loading={isPostalCodeLoading}
	   // Formik が管理する `postalCode` の値を value に代入する
	  value={formik.values.postalCode} 
	  // 逆に Autocomplete で更新した結果を Formik が管理する `postalCode` に代入する
	  onChange={(_: SyntheticEvent, newValue: string | null) =>
	    formik.setFieldValue("postalCode", newValue) 
	  }

Autocomplete の options には value に入力される値の配列を渡す

直感的には options には Autocomplete の選択リストに表示される文字列が入るものと想像していましたが、ちょっと違いました。

options には選択リストで選択した結果として value に入ることになる値の一覧を渡します。

このサンプルでは valuepostalCode: string が入ることを想定しているため、 postalCode を要素とした配列をセットしています。

          options={filteredPostalCodes?.map((p) => p.postalCode) ?? []}

options にセットされている値を異なる形式で表示する場合は getOptionLabel を利用する

前述のとおり options にセットする値はそのまま表示してもユーザーにとってわかりづらい値なので、それを分かりやすい(選択しやすい)表現に変換してあげる必要があります。

サンプルコードでは options には postalCode の配列を渡していますが、表示上はそれに対応した住所 (address) を表示することにしました。

関数の実装としては引数 optionoptions の配列要素 ( つまり postalCode ) が渡されるので、postalCodes の配列から該当する要素を特定し、その address を返すようにしています。

          getOptionLabel={(option: string) => {
            return (
              postalCodes.find((p) => p.postalCode === option)?.address ?? ""
            );
          }}

Autocomplete の「未選択」の状態を表す値は null

Autocomplete の「未選択」の状態を表す値(value)は null です。
ですので、Formik の initialValues では以下のように初期値として null をセットしています。

    initialValues: {
      postalCode: null
    },

これを間違えて "" (空文字列) をセットすると、 以下のようなワーニングが出てきます。

MUI: The value provided to Autocomplete is invalid.
None of the options match with `""`.
You can use the `isOptionEqualToValue` prop to customize the equality test. 

MUI は debounce も提供している

ググると大体 lodash を利用するか自前で実装する記事が出てきますが、実は MUI が debounce も提供しているのでわざわざ lodash への依存を追加したり自前実装しなくても良かったりします。

import { Autocomplete, TextField, debounce } from "@mui/material";

debounce を適用した関数は useMemo または useCallback でメモ化する

見逃しがちですが、 debounce でラップした関数はメモ化しないと正しく機能しないので注意が必要です。

  const debouncedSetter = useMemo(
    () => debounce((keyword: string) => setFilterKeyword(keyword), 500),
    []
  );

通常、関数をメモ化する際は useCallback フックを利用しますが、 ESLint が React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead. (react-hooks/exhaustive-deps) といって警告を出すので、 useMemo を使っています。

API に渡す検索用のキーワードは inputValue の値を利用する

サンプルコードでは usePostalCode カスタムフックに対して filterKeyword を与えることで、APIを呼び出してサーバから該当する郵便番号と住所の組み合わせのリストを取得する想定になっています。

  const [filterKeyword, setFilterKeyword] = useState<string>("");
  
  // 候補の一覧を抽出するカスタムHook
  const { data: filteredPostalCodes } = usePostalCode(filterKeyword);
  
  // ... (snip) ...
  
       onInputChange={(_, newInputValue) => debouncedSetter(newInputValue)}

その他Tips

Formik 関係無いですが MUI Autocomplete についての Tips を残しておきます。

オプションのkeyが重複する場合

Autocomplete のオプションのラベルが重複している場合以下のように key が重複したというエラーが出てくることがあります。

Warning: Encountered two children with the same key, `hoge`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.

これは MUI Autocomplete が生成する Option 要素のkeyの値はデフォルトでラベルの値を利用するようになっているからです。

https://stackoverflow.com/questions/69395945/how-can-i-add-unique-keys-to-react-material-ui-autocomplete-component/69396153#69396153

これを解決するには renderOption プロパティに自前でOption要素をレンダリングする関数を渡し、key として重複しないような値 (ユニークな id とか) を割り当てるようにします。

  renderOption: (props, option) => (
    <li {...props} key={option}>
      {getPlayerEmailById(option)}
    </li>
  ),

The value provided to Autocomplete is invalid を回避する

Autocomplete は基本的に選択されている値(value)はその選択肢(options)の中に含まれている前提で動作するようになっているようです。

ですので、自前で options の内容を絞り込むようなロジック ( 例えばAPIを呼び出して入力された文字列に該当する選択肢の一覧を取得するなど ) を実装している場合にこのワーニングが出てくることがあります。

MUI: The value provided to Autocomplete is invalid.
None of the options match with `"id0001"`.
You can use the `isOptionEqualToValue` prop to customize the equality test

このワーニングを回避するためには、options に現在選択されている value の値が必ず含まれていることを保証するような実装とします。

たとえば以下のように options に現在選択されている値 ( selectedUserId ) を追加します。

  const options = useMemo(() => {
    const userSet = new Set(users.map(u => u.id) ?? [])
    if (selectedUserId) userSet.add(selectedUserId)
    return Array.from(userSet)
  }, [users, selectedUserId])

また、以下では Set を利用することで options の要素として重複した値が含まれないことを保証しています。

さいごに

以上で Formik で作成したフォームに Autocomple を組み合わせて利用することができました。

さいごにポイントをまとめておきます。

  • Autocomplete の value の型は Formik の項目の型と一致させる
  • Autocomplete の options には value に入力される値の配列を渡す
  • options にセットされている値を異なる形式で表示する場合は getOptionLabel を利用する
  • Autocomplete の「未選択」の状態を表す値として null をセットする
  • MUI は debounce も提供しているので lodash に依存する必要はない
  • debounce を適用した関数は useMemo または useCallback でメモ化するのを忘れないようにする
  • 一覧取得 API の検索用のキーワードは inputValue の値を利用する

この記事が誰かの参考になれば幸いです。

参考リンク

https://formik.org/docs/api/formik

https://mui.com/material-ui/api/autocomplete/

Discussion