FormikとMUIでのオートコンプリートの実装方法
お仕事関係で表題のようなことをやりたかったけど、ネットをさがしても良いサンプルが見つけられず試行錯誤が必要だったので記事として公開しておきます。
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>
);
}
実装のポイント
いくつか実装のポイントがあったので、それらについて以下で簡単に解説します。
value
の型は Formik の項目の型と一致させる
Autocomplete の 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)
}
options
には value
に入力される値の配列を渡す
Autocomplete の 直感的には options
には Autocomplete の選択リストに表示される文字列が入るものと想像していましたが、ちょっと違いました。
options
には選択リストで選択した結果として value
に入ることになる値の一覧を渡します。
このサンプルでは value
に postalCode: string
が入ることを想定しているため、 postalCode
を要素とした配列をセットしています。
options={filteredPostalCodes?.map((p) => p.postalCode) ?? []}
options
にセットされている値を異なる形式で表示する場合は getOptionLabel
を利用する
前述のとおり options
にセットする値はそのまま表示してもユーザーにとってわかりづらい値なので、それを分かりやすい(選択しやすい)表現に変換してあげる必要があります。
サンプルコードでは options
には postalCode
の配列を渡していますが、表示上はそれに対応した住所 (address) を表示することにしました。
関数の実装としては引数 option
に options
の配列要素 ( つまり 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
を使っています。
inputValue
の値を利用する
API に渡す検索用のキーワードは サンプルコードでは 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の値はデフォルトでラベルの値を利用するようになっているからです。
これを解決するには 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
の値を利用する
この記事が誰かの参考になれば幸いです。
参考リンク
Discussion