【React Hook Form × Zod × MUI】フォームを作ろう!
はじめに
この記事で作るフォームのソースコードはこちら↓になります。
使用技術
- TypeScript
- React Hook Form
- Zod
- MUI
作成するフォーム
この記事で作成するフォームがこちらになります。
フォームの内容を整理していきます。
テキスト入力要素
必須入力と任意入力で二つの要素があります。
検索ボタンを押した時にエラーメッセージが出るのは必須入力の要素のみで、任意入力の方ではエラーは表示されません。
セレクト要素
テキスト入力要素と同じく、必須選択と任意選択で二つの要素があります。
また、こちらもテキスト入力要素と同じく、検索ボタンを押した時にエラーメッセージが出るのは必須選択の要素のみで、任意選択の方ではエラーは表示されません。
実装
それでは上記フォームを実装していきます!
実装方針
実装は以下の流れで進めていきます。
-
zod
を用いてschema
を定義 - React Hook Formを用いてカスタムフックを作成
- 2の返り値 + MUIを用いてUIを作成
前提
-
任意入力・任意選択のフォーム要素が未入力、未選択、もしくは空文字の状態で検索ボタンが押された場合、そのフォーム要素の値を
null
としてサーバーに渡す(実装内ではconsole.log
にて出力する)ように実装していきます。 -
フォーム要素それぞれの命名は以下とします。
- 入力要素
- 必須入力:
name
- 任意入力:
nullableName
- 必須入力:
- 選択要素
- 必須選択:
selectedValue
- 任意入力:
nullableSelectedValue
- 必須選択:
1. zodを用いてschemaを定義
手順1
フォームのsubmit時(gifで言う検索ボタンを押した時)に走るバリデーションをzodを用いて作成していきます。
コードはこちらになります。
// zodのpreprocessを使って、値が空文字の場合はnullに変換する
const castToValOrNull = <T extends Parameters<typeof z.preprocess>[1]>(
schema: T,
) =>
z.preprocess((val) => {
if (typeof val === 'string') {
const trimmedVal = val.trim()
return trimmedVal.length > 0 ? trimmedVal : null
}
return null
}, schema)
// フォームのsubmit時に走るschema
export const sampleFormSchema = z.object({
name: castToValOrNull(z.string()),
nullableName: castToValOrNull(z.string().nullable()),
selectedValue: castToValOrNull(z.string()),
nullableSelectedValue: castToValOrNull(z.string().nullable()),
})
-
castToValOrNull
について
zodのpreprocessを用いたメソッドになります。
渡ってきた値が空文字(""
)の場合、preprocess
にて値をnull
に変換します(未入力状態(null
)の場合はnull
のままです。)
その後にcastToValOrNull
の第二引数に渡したschema
によってバリデーションが行われます。
手順2
手順1で作成したschemaの型をzodのinferを用いてを定義します。
これらは「2. React Hook Formを用いてカスタムフックを作成」にて利用します。
export type SampleFormSchema = z.infer<typeof sampleFormSchema>
まとめ
手順1~2の実装を踏まえて、schemaは以下のようになります。
2. React Hook Formを用いてカスタムフックを作成
React Hook FormのuseFormというカスタムフックを用いて、3で実装する画面表示用のコンポーネントに渡す値をまとめるカスタムフックを作成していきます。
手順1
先述したカスタムフックのuseForm
を利用して、フォーム管理に必要な返り値を取得します。
(微々たるものではありますが、コメントアウトに解説を補足してあります。)
const {
control,
handleSubmit,
formState: { errors },
// useFormのジェネリクスにはdefaultValuesの型を渡す
} = useForm<SampleFormSchema>({
// modeをonBlurにすることで、初回validation時を検索ボタンが押されたタイミングに設定できる
mode: 'onSubmit',
// reValidateModeをonBlurにすることで、検索ボタンが押された後は常に入力値が変更されたタイミングでvalidationが走る
reValidateMode: 'onBlur',
// デフォルト状態はフォーム要素全てが未定義(undefined)の状態として取り扱う
defaultValues: undefined,
// zodResolverの引数にvalidation時に実行するschemaを渡す
resolver: zodResolver(sampleFormSchema),
})
手順2
useForm
の返り値のhandleSubmit
に渡すメソッド(onSubmit
とします)を実装します。
handleSubmit
はvalidationが通ったときにのみ実行されるメソッドで、引数(今回でいうバリデーションに通ったフォーム要素が入ったオブジェクト)をonSubmit
の引数に渡す役割があります。
// zodの値変換+型チェックを通過した場合のみonSubmitが呼ばれる
const onSubmit = (data: SampleFormSchema) => {
// zodの値変換+型チェックを通過した値
console.log('data', data)
}
手順3
値をreturnします。
return {
form: {
control,
handleSubmit,
onSubmit,
},
}
まとめ
手順1~3の実装を踏まえて、カスタムフックの実装は以下のようになります。
3. 2の返り値 + MUIを用いてUIを作成
2で作成したカスタムフックの返り値と、MUIを用いてUIを作成します。
手順1
UI表示用のコンポーネントの上部で2で作成したカスタムフックから値を受け取ります。
const SampleForm = () => {
const {
form: { control, handleSubmit, onSubmit },
} = useSampleForm()
return <></>
}
手順2
UI表示用のコンポーネントのreturn内に、画面に表示する内容を実装します。
SampleForm.tsx
:フォームの全体を表示するコンポーネント
※ RHFTextField.tsx
, RHFSelect.tsx
は手順3以降にて実装します
手順3
2で作成したカスタムフックの返り値と、MUIのフォームコンポーネントを用いて、テキスト入力用と選択用の共通コンポーネントを作成します。
RHFTextField.tsx
:テキスト入力用コンポーネント
RHFSelect.tsx
:選択用コンポーネント
上記のコンポーネントの実装をするにあたって個人的に重要だと感じたポイント2つとTipsを以下にて順に紹介します。
props
のvalue
について
ポイント1:MUIのフォームコンポーネントの以下のコードのように、フィールドの値がuseForm
のdefaultValues
オプションで指定したundefined
の場合は、空文字をvalueとして渡すように実装しています。
// 値がundefinedの場合は空文字に変換する
value={field.value ?? ''}
この実装をしない場合、以下のWarningがコンソールに出てしまうため、それを防ぐためにこの実装を行なっています。
Warning: A component is changing an uncontrolled input to be controlled.
This is likely caused by the value changing from undefined to a defined
value, which should not happen. Decide between using a controlled or
uncontrolled input element for the lifetime of the component.
Warningの原因
今回の実装ではフォームを制御(Controlled
)コンポーネントとしていますが、value
にundefined
を渡してしまうと、コンポーネントが非制御(uncontrolled
)コンポーネントになり、DOM自身がフォームデータを扱うようになってしまいます。それを防ぐためにvalue
がundefined
の場合は空文字を渡すように実装しています。
useController
について
ポイント2: テキスト入力、選択用のコンポーネントにてuseControllerを利用しています。
-
useController
のドキュメントから引用
This custom hook powers Controller. Additionally, it shares the same props and methods as Controller. It's useful for creating reusable Controlled input.
このuseControllerの利点は、上記のドキュメントからの引用にあるように、引数にuseForm
の返り値のname
とcontrol
を渡すことで、それぞれのフィールドに応じた値や状態を取得することができることです。
- 引数に
useForm
の返り値のname
とcontrol
を渡してフィールドに応じた値や状態を取得しているサンプルコード
const {
field,
formState: { errors },
} = useController({ name, control })
useController
の返り値をMUIのコンポーネントに渡す方法
Tips: 先述のRHFTextFieldの実装では、useControllerからの返り値のfieldをそのまま受け取り、それをMUIのコンポーネントであるTextFieldにfieldの中から値を取り出すような形でpropsとして渡しています。
// useControllerからfieldを受け取っている
const {
field,
formState: { errors },
} = useController({ name, control })
// TextFieldではfieldの要素をpropsとしてそれぞれ渡している
<TextField
// 値がundefinedの場合は空文字に変換する
value={field.value ?? ''}
inputRef={field.ref}
name={field.name}
onChange={field.onChange}
onBlur={field.onBlur}
/>
TextField
に渡しているpropsをよく見てみると、valueとinputRef以外は、fieldの中から渡す先のTextField
のpropsの名前と同じキーから取得して、値をそのまま渡しています。
ですので、rest parametersの機能を利用して以下のようにリファクタすることができます。
const {
// valueとrefと、それ以外(rest)をfieldから取り出して取得
field: { value, ref, ...rest },
formState: { errors },
} = useController({ name, control })
// TextFieldではfieldの要素をpropsとしてそれぞれ渡している
<TextField
// 値がundefinedの場合は空文字に変換する
value={field.value ?? ''}
inputRef={field.ref}
// その他の値(rest)を展開してTextFieldにpropsとして渡しています。
{...rest}
/>
感想
今回の記事ではReact Hook Formが提供しているカスタムフックの中で、useForm
とuseController
を利用しました。React Hook Formのドキュメントにはその他にもさまざまなカスタムフックが紹介されています。
最近、筆者はフロントエンド開発をすることが多いので、それらのカスタムフックを使いこなして(ライブラリの力を借りて)複雑なフォームをよりスムーズにバグなく開発していきたいと思います。
Discussion