🍣

【React Hook Form × Zod × MUI】フォームを作ろう!

2023/08/28に公開

はじめに

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

使用技術

作成するフォーム

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

フォームの内容を整理していきます。

テキスト入力要素

必須入力と任意入力で二つの要素があります。
検索ボタンを押した時にエラーメッセージが出るのは必須入力の要素のみで、任意入力の方ではエラーは表示されません。

セレクト要素

テキスト入力要素と同じく、必須選択と任意選択で二つの要素があります。
また、こちらもテキスト入力要素と同じく、検索ボタンを押した時にエラーメッセージが出るのは必須選択の要素のみで、任意選択の方ではエラーは表示されません。

実装

それでは上記フォームを実装していきます!

実装方針

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

  1. zodを用いてschemaを定義
  2. React Hook Formを用いてカスタムフックを作成
  3. 2の返り値 + MUIを用いてUIを作成

前提

  1. 任意入力・任意選択のフォーム要素が未入力、未選択、もしくは空文字の状態で検索ボタンが押された場合、そのフォーム要素の値をnullとしてサーバーに渡す(実装内ではconsole.logにて出力する)ように実装していきます。

  2. フォーム要素それぞれの命名は以下とします。

  • 入力要素
    • 必須入力: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は以下のようになります。
https://github.com/yohei222/react-hook-form-zod-mui/blob/main/src/schema/sample-form-schema.ts

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の実装を踏まえて、カスタムフックの実装は以下のようになります。
https://github.com/yohei222/react-hook-form-zod-mui/blob/main/src/hooks/useSampleForm.ts

3. 2の返り値 + MUIを用いてUIを作成

2で作成したカスタムフックの返り値と、MUIを用いてUIを作成します。

手順1

UI表示用のコンポーネントの上部で2で作成したカスタムフックから値を受け取ります。

const SampleForm = () => {
  const {
    form: { control, handleSubmit, onSubmit },
  } = useSampleForm()
  
  return <></>
}

手順2

UI表示用のコンポーネントのreturn内に、画面に表示する内容を実装します。

SampleForm.tsx:フォームの全体を表示するコンポーネント

https://github.com/yohei222/react-hook-form-zod-mui/blob/main/src/components/SampleForm.tsx

RHFTextField.tsx, RHFSelect.tsxは手順3以降にて実装します

手順3

2で作成したカスタムフックの返り値と、MUIのフォームコンポーネントを用いて、テキスト入力用と選択用の共通コンポーネントを作成します。

RHFTextField.tsx:テキスト入力用コンポーネント

https://github.com/yohei222/react-hook-form-zod-mui/blob/main/src/components/RHFTextField.tsx#L7-L42

RHFSelect.tsx:選択用コンポーネント

https://github.com/yohei222/react-hook-form-zod-mui/blob/main/src/components/RHFSelect.tsx#L12-L61

上記のコンポーネントの実装をするにあたって個人的に重要だと感じたポイント2つとTipsを以下にて順に紹介します。

ポイント1:MUIのフォームコンポーネントのpropsvalueについて

以下のコードのように、フィールドの値がuseFormdefaultValuesオプションで指定した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)コンポーネントとしていますが、valueundefinedを渡してしまうと、コンポーネントが非制御(uncontrolled)コンポーネントになり、DOM自身がフォームデータを扱うようになってしまいます。それを防ぐためにvalueundefinedの場合は空文字を渡すように実装しています。

ポイント2: useControllerについて

テキスト入力、選択用のコンポーネントにて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の返り値のnamecontrolを渡すことで、それぞれのフィールドに応じた値や状態を取得することができることです。

  • 引数にuseFormの返り値のnamecontrolを渡してフィールドに応じた値や状態を取得しているサンプルコード
const {
  field,
  formState: { errors },
} = useController({ name, control })

Tips: useControllerの返り値をMUIのコンポーネントに渡す方法

先述の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が提供しているカスタムフックの中で、useFormuseControllerを利用しました。React Hook Formのドキュメントにはその他にもさまざまなカスタムフックが紹介されています。
最近、筆者はフロントエンド開発をすることが多いので、それらのカスタムフックを使いこなして(ライブラリの力を借りて)複雑なフォームをよりスムーズにバグなく開発していきたいと思います。

株式会社モニクル

Discussion