📚

【React Hook Form×Zod×MUI】複数選択+オートコンプリート+型安全なフォームを作ろう!

2023/09/20に公開

はじめに

この記事で作るフォームのソースコードはこちら↓になります。
https://github.com/yohei222/react-hook-form-zod-mui

  • 【React Hook Form × Zod × MUI】フォームを作ろう!の続編になります。
  • ソースコードや実装方針などは前編のものを継承しています。
    • 前編の「フォームを作ろう!」では、テキスト入力フォームと選択フォームの実装例を紹介しています。

この記事では、型安全に複数選択可能でオートコンプリートなフォームを実装する方法の例を紹介していきます。

使用技術

作成するフォーム

この記事で作成するフォームがこちらになります。

実装

実装方針

実装は以下の流れで進めていきます。

  1. zodを用いてschemaを定義
  2. オートコンプリート単体のコンポーネントを実装
  3. 2で作成したコンポーネントにReact Hook Formを組み込んだコンポーネントを実装
  4. 3で作成したコンポーネントをビューに表示する実装

1. zodを用いてschemaを定義

前編の「zodを用いてschemaを定義」と同じ流れでschemaにフィールドを追加します。

// 最低でも一つは選択必須
multiOptions: z.array(z.string()).min(1),

補足

  1. この記事ではオートコンプリートなフィールドの命名はmultiOptionsとして進めていきます。
  2. この記事では、multiOptionsを最低でも一つは選択必須なオートコンプリートのフィールドにして進めて行こうと思います。そのため、.min(1)をzod schemaの末尾に追加しています。

2. オートコンプリート単体のコンポーネントを実装

これから作成するコンポーネント(以下、MultiAutocompleteとします)は、React Hook Formと組み合わせて利用することを想定してはいるものの、React Hook Formの世界に入っていなく、純粋にMUIの世界で完結するコンポーネントを指しています。

言い換えると、React Hook Formの世界のonChangevalueなどを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に関して、何も渡さない場合はデフォルト値が入るよう実装されていますので、今回は値を渡さずに進めていきます。

ChipPropsの説明(ドキュメント)

Props applied to the Chip element.

Chip ドキュメント

補足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