【React Hook Form×Zod×MUI】複数選択+オートコンプリート+型安全なフォームを作ろう!
はじめに
この記事で作るフォームのソースコードはこちら↓になります。
- 【React Hook Form × Zod × MUI】フォームを作ろう!の続編になります。
- ソースコードや実装方針などは前編のものを継承しています。
- 前編の「フォームを作ろう!」では、テキスト入力フォームと選択フォームの実装例を紹介しています。
この記事では、型安全に複数選択可能でオートコンプリートなフォームを実装する方法の例を紹介していきます。
使用技術
- TypeScript
- React Hook Form
- Zod
- MUI
作成するフォーム
この記事で作成するフォームがこちらになります。
実装
実装方針
実装は以下の流れで進めていきます。
-
zod
を用いてschema
を定義 - オートコンプリート単体のコンポーネントを実装
- 2で作成したコンポーネントにReact Hook Formを組み込んだコンポーネントを実装
- 3で作成したコンポーネントをビューに表示する実装
1. zodを用いてschemaを定義
前編の「zodを用いてschemaを定義」と同じ流れでschemaにフィールドを追加します。
// 最低でも一つは選択必須
multiOptions: z.array(z.string()).min(1),
補足
- この記事ではオートコンプリートなフィールドの命名は
multiOptions
として進めていきます。 - この記事では、
multiOptions
を最低でも一つは選択必須なオートコンプリートのフィールドにして進めて行こうと思います。そのため、.min(1)
をzod schemaの末尾に追加しています。
2. オートコンプリート単体のコンポーネントを実装
これから作成するコンポーネント(以下、MultiAutocomplete
とします)は、React Hook Formと組み合わせて利用することを想定してはいるものの、React Hook Formの世界に入っていなく、純粋にMUIの世界で完結するコンポーネントを指しています。
言い換えると、React Hook Formの世界のonChange
やvalue
などをMultiAutocomplete
にpropsとして渡すイメージです。
また、MultiAutocompleteの実装はMUIが提供しているAutocompleteコンポーネントを使って行います。
コンポーネントが返すJSXを実装する前に、まずMultiAutocomopleteコンポーネントのpropsの型を定義していきます。
型のコードがこちらになります。
// mui関連以外の型のまとまり
export type BaseAutocompleteProps<TOptionValue = unknown> = {
errorMessage?: string
inputRef?: RefCallBack
// onChangeはRHFのonChangeを利用したいため外部から渡す
onChange?: (value: TOptionValue[] | undefined) => void
} & Pick<TextFieldProps, 'placeholder'> &
Pick<RHFProps, 'label'>
// muiの型のまとまり
export type MuiAutocompleteProps<
TOptionValue = unknown,
TOptionExtra = unknown,
> = OriginalMuiAutocompleteProps<
// ** ジェネリクスについて **
// 第一引数: Optionの型を渡す
// * @param {Value} option The option to render.
// 第二引数:multipleかtrueかどうか→trueを渡す
{
value: TOptionValue
label: string
} & TOptionExtra,
true,
false,
false
>
// mui関連の型のまとまり
export type CustomMuiProps<
TOptionValue = unknown,
TOptionExtra = unknown,
> = Omit<
MuiAutocompleteProps<TOptionValue, TOptionExtra>,
// renderInput: このコンポーネント内で定義するため不要
// onChange: RHFのonChangeをこのコンポーネントに渡すため不要
// multiple: 複数選択前提のコンポーネントのため不要
// value: valuesと命名して定義するためここではOmit
'renderInput' | 'onChange' | 'multiple' | 'value'
> & {
// 複数選択のため、valueは配列となる→配列として扱いやすくするためvalueをvaluesと命名して定義
values: Pick<
MuiAutocompleteProps<TOptionValue, TOptionExtra>,
'value'
>['value']
}
// mui関連 + その他の型を連結させた型(MultiAutocompleteの型)
export type MultiAutocompleteProps<TValue, TOptionExtra> =
BaseAutocompleteProps<TValue> & CustomMuiProps<TValue, TOptionExtra>
/* TValue: オプションの値の型
* TOptionValue: オプションの追加情報の型(オプションの値以外の型) */
// RHFなしで単体で使用するためのコンポーネント
export const MultiAutocomplete = <TValue = unknown, TOptionExtra = unknown>({
...props
}: MultiAutocompleteProps<TValue, TOptionExtra>) => {
// ここにJSXを返す実装を書く
}
実装手順やtypeの内容をそれぞれ補足していきます。
補足1. type MuiAutocompletePropsの作成
コードはこちらになります。
// muiの型のまとまり
import {
AutocompleteProps as OriginalMuiAutocompleteProps,
} from '@mui/material'
export type MuiAutocompleteProps<
TOptionValue = unknown,
TOptionExtra = unknown,
> = OriginalMuiAutocompleteProps<
// ・ジェネリクスについて
// 第一引数: Optionの型を渡す
// * @param {Value} option The option to render.
// 第二引数:multipleかtrueかどうか→trueを渡す
{
value: TOptionValue
label: string
} & TOptionExtra,
true,
false,
false
>
このコードでは、MUIが提供しているAutocompleteProps(コード内ではOriginalMuiAutocompletePropsとしています)に適切なジェネリクスを渡して、その返り値をMuiAutocompletePropsとして定義しています。
MuiAutocompletePropsに渡しているジェネリクスについて
2つあるジェネリクスにどのような型を入れるかの補足になります。
-
TOptionValue
ここには、optionのvalueの型が入ります。 -
TOptionExtra
ここには、optionのハッシュに含まれるvalueとlabel以外のキーとその型のセットが入ります。
たとえば、optionの型が以下のExampleであった場合、valueとlabelを除いた{ "color": string }
という型が入ります。
type Example = {
"value": string,
"label": string,
"color": string,
}
OriginalMuiAutocompletePropsに渡しているジェネリクスについて
VSCodeにてOriginalMuiAutocompletePropsにカーソルを当てて、型のジェネリクスを確認してみると、このようになっています。
それぞれのジェネリクスについて入れる値は以下になります。
-
第1引数
Valueとあるので、表示するOptionの型を渡します。
※node_modules/@mui/material/Autocomplete/Autocomplete.d.ts
ファイルに// * @param {Value} option The option to render.
というコメントアウトがあったので、Valueには表示するOptionの型を渡すのが適切だと気づきました。 -
第2引数
Multiple extends boolean | undefined
となっています。
今回は複数選択可能にしたい為、trueを入れます。 -
第3引数
DisableClearable extends boolean | undefined
となっています。
今回はインプット要素をクリアできるようにしたいのでfalseを入れます。If true, the input can't be cleared.
-
第4引数
FreeSolo extends boolean | undefined
となっています。
free soloをtrueにすると、自由入力された値をインプットとして保持できるようになります。
今回は、用意したオプションのvalueのみを値として保持したいため、falseを入れます。If true, the Autocomplete is free solo, meaning that the user input is not bound to provided options.
-
第5引数
ChipComponent extends React.ElementType = ChipTypeMap['defaultComponent']
となっています。
ChipComponentの型は、ライブラリ内にてChipPropsのジェネリクスとして使用されています。
そのChipPropsは、チップをカスタムするために使われるAutocompleteのpropsになります。
ChipComponentに関して、何も渡さない場合はデフォルト値が入るよう実装されていますので、今回は値を渡さずに進めていきます。
Props applied to the Chip element.
補足2. type BaseAutocompletePropsの作成
コードはこちらになります。
export type BaseAutocompleteProps<TOptionValue = unknown> = {
errorMessage?: string
inputRef?: RefCallBack
// onChangeはRHFのonChangeを利用したいため外部から渡す
onChange?: (value: TOptionValue[] | undefined) => void
} & Pick<TextFieldProps, 'placeholder'> &
Pick<RHFProps, 'label'>
この型では、MUIに関連しない型の一覧を定義しています。
たとえば、onChangeはフォームライブラリが提供しているフックの返り値の値をそのままこのコンポーネントに渡したいため、BaseAutocompletePropsの中に定義しています。
補足になりますが、この記事の後半では、React Hook FormのuseControllerの返り値のonChange
をこのコンポーネントに渡します。
補足3. type CustomMuiPropsの作成
コードはこちらになります。
// mui関連の型のまとまり
export type CustomMuiProps<
TOptionValue = unknown,
TOptionExtra = unknown,
> = Omit<
MuiAutocompleteProps<TOptionValue, TOptionExtra>,
// renderInput: このコンポーネント内で定義するため不要
// onChange: RHFのonChangeをこのコンポーネントに渡すため不要
// multiple: 複数選択前提のコンポーネントのため不要
// value: valuesと命名して定義するためここではOmit
'renderInput' | 'onChange' | 'multiple' | 'value'
> & {
// 複数選択のため、valueは配列となる→配列として扱いやすくするためvalueをvaluesと命名して定義
values: Pick<
MuiAutocompleteProps<TOptionValue, TOptionExtra>,
'value'
>['value']
}
この型では、MuiAutocompletePropsから不要な型の排除や、propsの名前の変更を行なっています。
ポイント
CustomMuiPropsでは、わざわざMuiAutocompletePropsのvalueをvaluesとして再命名しています。
その理由としては、OriginalMuiAutocompletePropsの第二引数(multipleなオートコンプリートのフォームにするかのbool値)にtrueを渡す場合、OriginalMuiAutocompletePropsのvalueが必ず「選択されているオプションの配列の型」になる為、配列であるという意味を込めて複数形のvaluesとして定義しています。
補足4. type MultiAutocompletePropsの作成
コードはこちらになります。
export type MultiAutocompleteProps<TValue, TOptionExtra> =
BaseAutocompleteProps<TValue> & CustomMuiProps<TValue, TOptionExtra>
MultiAutocompletePropsは、「mui関連 + その他の型を連結させた型」になります。
この型を今回作成するMultiAutocompleteのpropsの型として扱います。
MultiAutocompleteが返すJSXの実装
コードはこちらになります。
/* TValue: オプションの値の型
* TOptionValue: オプションの追加情報の型(オプションの値以外の型) */
// RHFなしで単体で使用するためのコンポーネント
export const MultiAutocomplete = <TValue = unknown, TOptionExtra = unknown>({
errorMessage,
options,
values,
inputRef,
placeholder = '選択してください',
onChange,
label,
...props
}: MultiAutocompleteProps<TValue, TOptionExtra>) => {
return (
<Box>
<FormControl error={!!errorMessage}>
<Stack direction="row" alignItems="center" m={2}>
<Typography variant="body1" mr={2}>
{label}:
</Typography>
<MuiAutocomplete
{...props}
sx={{
width: 300,
}}
// multipleなAutocompleteとして扱いたいので、multipleにtrueを渡す
multiple={true}
disableCloseOnSelect={true}
onChange={(_, selectedOptions) => {
// optionのvalueのみを取り出す
const values =
selectedOptions?.map((option) => option.value) ?? []
// values(valueの型の配列)をRHFで管理している値に渡す
onChange?.(values)
}}
disablePortal
options={options}
// MUIのvalueは配列になる(OriginalMuiAutocompletePropsのジェネリクスの第二引数を配列にした為)
// valuesがundefinedの場合は空の配列を渡す
value={values ?? []}
renderInput={(params) => (
<TextField
{...params}
error={!!errorMessage}
// inputRef: https://mui.com/material-ui/api/text-field/#TextField-prop-inputRef
inputRef={inputRef}
placeholder={placeholder}
/>
)}
/>
{!!errorMessage && (
<FormHelperText>{errorMessage as string}</FormHelperText>
)}
</Stack>
</FormControl>
</Box>
)
}
ポイント1: onChangeについて
VSCodeにて確認すると、MuiAutocompleteのonChangeの第二引数(コードでのselectedOptions)は、optionの配列であるようです。
ですが、propsから渡ってくるフォーム管理ライブラリにて定義されたonChangeには、選択されているoptionのvalueのみの配列を渡したいです。
そのため、まず選択されているoptionのvalueのみを取り出す処理をしてから、その値(配列)をonChangeの引数に渡しています。
該当のコードはこちら↓になります。
onChange={(_, selectedOptions) => {
// optionのvalueのみを取り出す
const values =
selectedOptions?.map((option) => option.value) ?? []
// values(valueの型の配列)をRHFで管理している値に渡す
onChange?.(values)
}}
ポイント2: valuesの渡し方
フォームを制御コンポーネントとして扱うために、valuesがundefinedの場合は空の配列を渡しています。
詳しくは、前編の「MUIのフォームコンポーネントのpropsのvalueについて」に記載しています。
該当のコードはこちら↓になります。
// MUIのvalueは配列になる(OriginalMuiAutocompletePropsのジェネリクスの第二引数を配列にした為)
// valuesがundefinedの場合は空の配列を渡す
value={values ?? []}
これにて、オートコンプリート単体のコンポーネントの実装が完了になります。
3. 2で作成したコンポーネントにReact Hook Formを組み込んだコンポーネントを実装
これから、2で作成したコンポーネントにReact Hook Formを組み込んだコンポーネント(以下、RHFMultiAutocompleteとします)を実装していきます。
RHFMultiAutocompleteコンポーネントのpropsの型を定義する
2と同様に、まずはコンポーネントに渡すpropsの型を定義するところから始めます。
コードはこちらになります。
export type RHFMultiAutocomplete<
T extends FieldValues,
TValue = unknown,
TOptionExtra = unknown,
> =
// RHFProps: RHFの型のまとまり
RHFProps<T> &
// BaseAutocompleteProps: mui関連以外の型のまとまり
// errorMessageはuseControllerから受け取るためOmitします
Omit<BaseAutocompleteProps<TValue>, 'errorMessage'> &
// CustomMuiProps: mui関連の型のまとまり
Pick<
CustomMuiProps<TValue, TOptionExtra>,
| 'options'
| 'renderOption'
| 'sx'
| 'getOptionLabel'
| 'isOptionEqualToValue'
| 'filterOptions'
| 'noOptionsText'
| 'disabled'
>
export type RHFProps<T extends FieldValues = FieldValues> = Pick<
UseControllerProps<T>,
'name' | 'control'
> & {
label: string
}
補足: type RHFMultiAutocompleteについて
まず、RHFProps<T>
として、React Hook Formから受け取るpropsを定義します。次に、2のMultiAutocomplete作成時に定義した2つのtype(BaseAutocompleteProps, CustomMuiProps)から必要なものだけを取り出した型を&
で連結します。
RHFMultiAutocompleteが返すJSXの実装
コードはこちらになります。
export const RHFMultiAutocomplete = <
T extends FieldValues,
// オプションの値の型
TValue = unknown,
// オプションの追加情報の型(オプションの値以外の型)
TOptionExtra = unknown,
>({
name,
control,
options,
...props
}: RHFMultiAutocomplete<T, TValue, TOptionExtra>) => {
const {
// 複数選択フォームのためuseControllerの返り値のvalueは配列となるため、valuesと再命名しています
field: { ref, value: values, ...rest },
formState: { errors },
} = useController<T>({ name, control })
const errorMessage = errors?.[name]?.message as string
// valuesの値を元に、選択されているオプションを抽出
const selectedOptions = useMemo(
() => options.filter((option) => values?.includes(option.value)),
[options, values],
)
return (
<MultiAutocomplete
{...props}
{...rest}
options={options}
// 現在選択されているオプションのみをvaluesに渡す
values={selectedOptions}
inputRef={ref}
errorMessage={errorMessage}
/>
)
}
ポイント1
React Hook FormのuseControllerの返り値のvalueは、RHFMultiAutocompleteが複数選択前提のコンポーネントであることから必ず配列型になります。ですので、困惑を避けるためにvalueをvaluesとして再命名しています。
const {
// 複数選択フォームのためuseControllerの返り値のvalueは配列となるため、valuesと再命名しています
field: { ref, value: values, ...rest },
formState: { errors },
} = useController<T>({ name, control })
ポイント2
2のMultiAutocompleteの実装にて、React Hook Form側ではmultiOptionsフィールドの値はvalueの配列として扱えるように実装しました。
ですが、MUIのAutocompleteコンポーネント内ではvalueがOptionの配列の型として扱われているため、RHFMultiAutocomplete内にてvalueの配列からOptionの配列に変更してvalueを渡す必要があります。
該当コードはこちら↓になります。
※ optionsがたくさんあった場合、処理に時間がかかる可能性があるので、useMemoにてメモ化を行なっています。
// valuesの値を元に、選択されているオプションを抽出
const selectedOptions = useMemo(
() => options.filter((option) => values?.includes(option.value)),
[options, values],
)
これにて、RHFMultiAutocompleteコンポーネントの実装が完了になります。
4. 3で作成したコンポーネントをビューに表示する実装
コードはこちらになります。
※ useSampleFormは前編で実装した、React Hook Formに関連する値やオプション一覧を返すカスタムフックになります。
const {
form: { control, handleSubmit, onSubmit },
options: { options, optionsWithColor },
} = useSampleForm()
<RHFMultiAutocomplete
name="multiOptions"
label="複数選択"
control={control}
options={optionsWithColor}
// renderOption: https://mui.com/material-ui/api/autocomplete/#Autocomplete-prop-renderOption
renderOption={(props, option) => {
// props: mui側で用意されている定数や関数が複数入っている
return (
<Box {...props} component="li" color={option.color}>
{option.label}
</Box>
)
}}
/>
renderOptionはMUIのAutocompleteのpropsを用いて機能をリッチにする実装の一例になります。
MUIのAutocompleteには便利なpropsがたくさんあるようでして、公式ドキュメントからpropsの一覧を確認することができます。
以上で、型安全かつ複数選択可能でオートコンプリートなフォームを作成することができました!
感想
目指せインコンパチ職人
今回、この記事の実装をするにあたって、何度かtscのエラーになってしまい、型パズルを解くための時間が少なからず発生してしまいました。
エラーの内容で言うと、以下のようなpropsの型と渡している値の型が合わないエラーが多く発生してしまいました。
Types of property 'hoge' are incompatible
筆者の働いている会社のチームでは、このエラーのことを「インコンパチ」(incompatibleの省略?)と読んでいます。「インコンパチ職人になってスムーズに型パズルを解けるようになること」が、筆者の直近のTypeScriptを用いた開発での目標の一つです。
その他
実装する上で大事だなと改めて感じたのが以下の二点になります。
-
責務の分離
今回の実装の例では、MUIを利用した表示の責務を持つMultiAutocompleteと、それにRHFを組み合わる責務を持つRHFMultiAutocompleteを別々のコンポーネントとして作成しました。
これにより、コードの関心の分離が図れるので、コードの意図をよりスムーズに理解できたり、コード改修時のメンテナンスコストの削減に繋げられそうだと思います。 -
命名
この記事の中では、valueという名前で命名されているのにも関わらず、配列が返ってきている際に、valuesとして再命名するような実装をしています。
こうすることで、今回のような複雑な型を扱うときに配列か配列でないかの困惑を防げるかと思っています。
最後に
この長い記事を読んでいただきありがとうございます!
少しでも面白いな〜、タメになった〜と思っていただけたら幸いです!
宣伝
筆者が働いている株式会社モニクルではソフトウェアエンジニアを募集しています!
こちら↓から会社情報を見ていただけると嬉しいです!
Discussion