MUI + React-Hook-Form + Zod で動的に選択肢が変化するフォームの作成
コードは以下のrepositoryにおいています。
使用したライブラリのバージョンは以下の通りです。
- swr: 2.2.4
- zod: 3.22.4
- react-hook-form: 7.49.2
- next: 14.0.4
- axios: 1.6.5
背景と目的
フォームを作成するのに、以下の観点からZod・React-Hook-Form・MUI を使用することはよくあると思います。
- バリデーションのためにZodを使用したい。
- フォームの状態管理やデータのバリデーション実行をReact-Hook-Formに任せたい。
- いい感じのデザインを考えるのが面倒なため、MUIを使用したい。
- APIのキャッシュや競合を防ぐため、
useSWR
を用いたい。
この記事では、これらの技術スタックにおいて、動的に選択肢が変化するフォームを作成したので共有します。
動的に選択肢が変化するというのは、1つ前の項目の入力内容に応じて、次の項目の入力の選択肢が変化するというものです。
次に具体的な成果物について説明します。
作るフォームの内容
成果物は以下のような3つの項目があるフォームです。それぞれの項目は必須のフォームです。
2つめの Repository
は1つめの User Name
に応じた選択肢が取得され、選択可能となります。もし、Repository
がない User Name
を指定した場合は No Itemとなります。 User Name
は Repository
の値をもとにバックエンドに問い合わせ、選択肢を取得します。
同様に、3つめの File
は 1, 2つめの User Name
, Repository
に応じて選択肢が取得されます。もし存在しないUser Name
, Repository
を指定した場合は同様に No Item となります。
また、フォームの各項目を埋めたあとに、依存先の項目の内容が修正された場合、依存していた項目の選択を解除します。例えば、以下は、上画像からUser Nameを削除した場合の結果です。Repository
とFile
はそれぞれUser Name
に依存していたため、そのUser Name
が変更された場合は選択肢を解除します。
選択肢を動的に変化させるには
ここでは、依存関係がある Repository
と File
の選択肢のコンポーネント SelectField
について解説します。
'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
を渡されたあとの処理を説明します。
<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であり、この項目の入力された値を設定するためのイベントリスナーになります。
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がコールされ、動的に選択肢が変化します。
const repository = useRepositoryOptions(useFormMethod.watch('user'));
const file = useFileOptions(useFormMethod.watch('repository'));
- 上記のコード部分
-
useReposotory, useFileOptions では
useSWR
を用いてAPIを呼び出しています。
おわりに
1つ前の選択肢に依存したフォームを作成しようと思ったときにサンプルコードが落ちていなかったため、今回は簡単に作成したフォームを共有しました。
ただ、今のコードの場合は以下の点が問題です。
-
TextField
など、入力が開始してから完了するまで時間がかかる項目に関しては、その都度APIを呼びに行くのは非効率。- useDebounce を用いて一定待ち時間を確保すると良さそうです。
- 親コンポーネントで依存関係を管理しているため、変更点のないコンポーネントも再レンダリングされる
- Memo化を行うことで解消することができます。しかし、Meomo化を行うとコードが複雑になるため、パフォーマンスの問題があってから対応でも良いと思います。
参考記事