MUI + React-Hook-Form + Zod で選択肢が動的に変化するフォームの作成

# MUI + React-Hook-Form + Zod で動的に選択肢が変化するフォームの作成

コードは以下のrepositoryにおいています。

https://github.com/haru-256/react-hook-form-zod-mui

使用したライブラリのバージョンは以下の通りです。
- swr: 2.2.4
- zod: 3.22.4
- react-hook-form: 7.49.2
- next: 14.0.4
- axios: 1.6.5


## 背景と目的

フォームを作成するのに、以下の観点から[Zod](https://github.com/colinhacks/zod)・[React-Hook-Form](https://react-hook-form.com/)・[MUI](https://mui.com/) を使用することはよくあると思います。

- バリデーションのために[Zod](https://github.com/colinhacks/zod)を使用したい。
- フォームの状態管理やデータのバリデーション実行を[React-Hook-Form](https://react-hook-form.com/)に任せたい。
- いい感じのデザインを考えるのが面倒なため、[MUI](https://mui.com/)を使用したい。
- APIのキャッシュや競合を防ぐため、`useSWR`を用いたい。

この記事では、これらの技術スタックにおいて、動的に選択肢が変化するフォームを作成したので共有します。
動的に選択肢が変化するというのは、1つ前の項目の入力内容に応じて、次の項目の入力の選択肢が変化するというものです。
次に具体的な成果物について説明します。


## 作るフォームの内容

成果物は以下のような3つの項目があるフォームです。それぞれの項目は必須のフォームです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200359/0eb71bd0-159d-6dfd-5ca7-421dc8137c67.png)

2つめの `Repository` は1つめの `User Name` に応じた選択肢が取得され、選択可能となります。もし、`Repository` がない `User Name` を指定した場合は No Itemとなります。 `User Name` は `Repository` の値をもとにバックエンドに問い合わせ、選択肢を取得します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200359/49f3ea2a-255b-957d-5204-831ace8bed89.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200359/e907a41a-22c3-bf9f-27fb-ccc176e72529.png)

同様に、3つめの `File` は 1, 2つめの `User Name`, `Repository` に応じて選択肢が取得されます。もし存在しない`User Name`, `Repository` を指定した場合は同様に No Item となります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200359/9eb83caf-48f3-07e4-d625-2680aba65a9d.png)

また、フォームの各項目を埋めたあとに、依存先の項目の内容が修正された場合、依存していた項目の選択を解除します。例えば、以下は、上画像からUser Nameを削除した場合の結果です。`Repository`と`File`はそれぞれ`User Name`に依存していたため、その`User Name`が変更された場合は選択肢を解除します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/200359/096cb272-ac37-68d8-375c-7aae85814da7.png)


## 選択肢を動的に変化させるには

ここでは、依存関係がある `Repository` と `File`の選択肢のコンポーネント `SelectField` について解説します。

```tsx:src/components/SelectField/index.tsx
'use client';

import React from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import {
  Select,
  MenuItem,
  FormControl,
  InputLabel,
  SelectChangeEvent,
  FormHelperText,
} from '@mui/material';
import { type FormType } from '@/schema/form';

type SelectFieldProps = UseControllerProps<FormType> & {
  label: string;
  options: { label: string; value: string }[]; // 動的に変化する選択肢。選択肢は親コンポーネントのフォームで取得する（後述）
  required?: boolean;
  initValue?: boolean;
  errorMessage?: string;
  onChangePre?: (e: SelectChangeEvent<string>) => void; // 依存しているフォームの初期化をするためのイベントリスナー（後述）
};

export default function SelectField({
  label,
  required,
  options,
  initValue,
  errorMessage,
  ...props
}: SelectFieldProps) {
  const { field } = useController(props);
  return (
    <FormControl fullWidth required={required ? true : false}>
      <InputLabel>{label}</InputLabel>
      <Select
        {...field}
        label={label}
        onChange={(e) => {
          props.onChangePre && props.onChangePre(e);
          field.onChange(e);
        }}
      >
        {options.map((option) => (
          <MenuItem key={option.value} value={option.value}>
            {option.label}
          </MenuItem>
        ))}
      </Select>
      {errorMessage && <FormHelperText error>{errorMessage}</FormHelperText>}
    </FormControl>
  );
}

```

フォームの依存関係は、 `SelectField` を持つ親コンポーネント `Form` で管理しています。
例えば、1つめの`User Name`の値が変わると、2, 3つめの`Repository`, `File`の選択肢が変化するため、それらで現在設定している値を空文字列（=選択なし）に設定しています（以下が該当部分）。選択している値はreact-hook-formで管理しているため、`setValue`を用いて初期化しています。
上記の処理を行うために、コンポーネントのpropsには`onChangePre`を渡しています。次に `onChangePre` を渡されたあとの処理を説明します。

https://github.com/haru-256/react-hook-form-zod-mui/blob/main/src/components/Form/index.tsx#L61-L70

```tsx
<TextField
  label="User Name"
  required
  control={useFormMethod.control}
  name="user"
  onChangePre={(e) => {
    useFormMethod.setValue('repository', '');
    useFormMethod.setValue('file', '');
  }}
/>
```

以下のように、`onChangePre`は`MUI`の`TextField`コンポーネントに渡され、`onChange`として設定しています。 `field.onChange(e)` はreact-hook-form のmethodであり、この項目の入力された値を設定するためのイベントリスナーになります。

https://github.com/haru-256/react-hook-form-zod-mui/blob/main/src/components/TextFiled/index.tsx#L28-L31

```tsx
export default function TextField({
  label,
  required,
  onChangePre,
  ...props
}: UseControllerProps<FormType> & {
  label: string;
  required?: boolean;
  onChangePre?: (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => void;
}) {
  const { field } = useController(props);

  return (
    <MUITextField
      {...field}
      label={label}
      required={required ? true : false}
      onChange={(e) => {
        onChangePre && onChangePre(e); // このコンポーネントの入力を変更する前に依存しているコンポーネントの値を初期化する
        field.onChange(e);
      }}
    />
  );
}
```

1つめ、2つめの選択肢の依存に応じてそれ以降の項目の選択肢を動的に変化させるために、react-hook-formの`watch`を用いて変数を購読しています。これにより、入力が変化するたびにAPIがコールされ、動的に選択肢が変化します。

```tsx
const repository = useRepositoryOptions(useFormMethod.watch('user'));
const file = useFileOptions(useFormMethod.watch('repository'));
```

- [上記のコード部分](https://github.com/haru-256/react-hook-form-zod-mui/blob/main/src/components/Form/index.tsx#L38-L39)
- [useReposotory, useFileOptions](https://github.com/haru-256/react-hook-form-zod-mui/blob/main/src/lib/index.ts#L32-L82) では`useSWR`を用いてAPIを呼び出しています。




## おわりに

1つ前の選択肢に依存したフォームを作成しようと思ったときにサンプルコードが落ちていなかったため、今回は簡単に作成したフォームを共有しました。

ただ、今のコードの場合は以下の点が問題です。

- `TextField`など、入力が開始してから完了するまで時間がかかる項目に関しては、その都度APIを呼びに行くのは非効率。
    - [useDebounce](https://github.com/xnimorz/use-debounce) を用いて一定待ち時間を確保すると良さそうです。
- 親コンポーネントで依存関係を管理しているため、変更点のないコンポーネントも再レンダリングされる
  - Memo化を行うことで解消することができます。しかし、Meomo化を行うとコードが複雑になるため、パフォーマンスの問題があってから対応でも良いと思います。

## 参考記事

https://zenn.dev/monicle/articles/4868424f22d6f5

https://zenn.dev/takepepe/articles/rhf-usewatach