バリデーションフォームをカスタムフックと共通コンポーネント化する【react-hook-form+MUI+zod 】

# 経緯

現在従事しているプロジェクトにて、フォームのUIを実装しています。今まであまり触れてこなかったバリデーションライブラリと、`react-hook-form`を使ったので備忘録です。

個人実装と違い、色々な画面に沢山のフォームを使うのでバリデーションの`Schema`を一つのファイルにまとめて置きたいのと、`useForm`をカスタムフック化することでコードを簡潔にし、再利用性を高めたかったので、`TextField`（MUIのコンポーネントでinputタグに相当する）をバリデーション用にコンポーネント化したものと、`useForm`を実装しました。

調べても`Schema`を別管理にしている例があまり見つからなかったので、型付けに苦労しましたが、スッキリと書けてSchemaの管理も効率的になるかなと思います。

`react-hook-form`以外は初めて使ったので良い勉強になりました！

# 前提条件

## 本記事の対象

- 駆け出しエンジニア
- カスタムフックについて基本を理解している
- **Next.js**の`app router`について基本を理解している
- **Next.js**と**TypeScript**での開発ができる

## 必要環境とライブラリ

以下の環境とライブラリのバージョンがプロジェクトにインストールされていることを確認してください。`zod`に関しては最新だとエラーがでてしまいますので、インストール時にバージョン指定が必要です。

- `zod`: `3.21.x`
    
    ※2023/12/18日時点で3.22.xだとreact-hook-formと一緒に使うとタイプエラーが出てしまいます。最終的に3.21にダウングレードしたら解決しました。
    
- `react-hook-form`: `7.49.2`
- `react`: `18.x`
- `next`: `14.0.4`
- `typescript`: `5.x`
- `@mui/material`: `5.15.0`

## **各ライブラリの詳細な概要と役割**

### `zod`: 効果的なデータバリデーション

`zod`は、TypeScriptとJavaScriptのためのバリデーションライブラリです。このライブラリの主な目的は、入力データが特定のスキーマに適合するかどうかをチェックすることです。これにより、フォームやAPIから受け取るデータが予期した形式かどうかを保証できます。

- **使用例**: メールアドレスやパスワードなどのフォーム入力が適切な形式を持っているかを確認する。
- **メリット**: コードベースで明確なバリデーションルールを設定でき、エラーメッセージの管理も容易になります。

### `react-hook-form`: 効率的なフォーム管理

`react-hook-form`は、Reactアプリケーション内でフォームの状態管理とバリデーションを簡素化します。パフォーマンスを重視して設計されており、不要なレンダリングを最小限に抑えることができます。

- **使用例**: フォームの入力値を追跡し、ユーザーの操作に基づいてバリデーションの結果を反映する。
- **メリット**: フォームの処理を効率化し、開発者がフォームの状態管理に費やす時間を大幅に削減できます。

### `@mui/material`: 魅力的なUIコンポーネント

`@mui/material`は、マテリアルデザインを基にしたReactコンポーネントライブラリです。一貫性のあるデザイン言語を提供し、使いやすいUIを迅速に構築できます。

- **使用例**: ボタン、テキストフィールド、ダイアログボックスなどのUI要素をプロジェクトに迅速に組み込む。
- **メリット**: デザインの一貫性が保たれ、ユーザーに親しみやすいインターフェイスを提供できます。また、カスタマイズも容易で、さまざまなデザインニーズに対応可能です。

# 成果物(メールアドレスが一致するかを検証するバリデーション)

### [GitHubリポジトリ](https://github.com/dende-h/sample-validation)

## 本記事の目標成果物

- MUIの`TextField`をバリデーション用にコンポーネント化します。
- `useForm`を`zod`と結合し、`schema`を別ファイルにまとめて見通しのよい構成で再利用性の高いカスタムフックを作成します。

![スクリーンショット 2023-12-18 160724.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1614172/53701f53-06aa-09d9-3d33-82665afbd6a7.png)

![スクリーンショット 2023-12-18 160603.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1614172/04361a91-0426-c310-e181-2af51658bfe0.png)

![スクリーンショット 2023-12-18 160501.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1614172/95c4dd57-f319-b8bd-b244-51b7a86ba0e9.png)

# 実装

## 環境構築

- コマンドから対話式の設定をして、Next.jsのプロジェクトを作成。
    
    ```bash
    npx next-create-app@latest
    
    Need to install the following packages:
      create-next-app@14.0.4
    Ok to proceed? (y) y
    √ What is your project named? ... sample-validation
    √ Would you like to use TypeScript? ... No / Yes
    √ Would you like to use ESLint? ... No / Yes
    √ Would you like to use Tailwind CSS? ... No / Yes
    √ Would you like to use `src/` directory? ... No / Yes
    √ Would you like to use App Router? (recommended) ... No / Yes
    √ Would you like to customize the default import alias (@/*)? ... No / Yes
    Creating a new Next.js app in C:\Users\yuu-mem11\workSpace\sample-validation.
    ```
    
- 必要なライブラリをインストール
    
    ```bash
    npm install @mui/material @emotion/react @emotion/styled @hookform/resolvers react-hook-form zod@3.21
    ```
    

### ディレクトリの構成

- appディレクトリを使った下図のような構成で作成します。分かりやすくするために、app配下のデフォルトファイルは削除しています。
    
    ![スクリーンショット 2023-12-18 160111.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1614172/750e8c82-075d-c366-34d1-06480180cd0e.png)

    
- その他の設定等は`npx next-create-app@latest`で作成されたデフォルト設定のままです。
    
    ```json:/package.json
    {
      "name": "sample-validation",
      "version": "0.1.0",
      "private": true,
      "scripts": {
        "dev": "next dev",
        "build": "next build",
        "start": "next start",
        "lint": "next lint"
      },
      "dependencies": {
        "@emotion/react": "^11.11.1",
        "@emotion/styled": "^11.11.0",
        "@hookform/resolvers": "^3.3.2",
        "@mui/material": "^5.15.0",
        "next": "14.0.4",
        "react": "^18",
        "react-dom": "^18",
        "react-hook-form": "^7.49.2",
        "zod": "3.21"
      },
      "devDependencies": {
        "@types/node": "^20",
        "@types/react": "^18",
        "@types/react-dom": "^18",
        "eslint": "^8",
        "eslint-config-next": "14.0.4",
        "typescript": "^5"
      }
    }
    ```
    

# コード実装手順

## ステップ1: 共通のテキストフィールドコンポーネントの作成

まず、`@mui/material`の`TextField`をラップして、バリデーションとエラーメッセージを表示できる共通のコンポーネント`ValidationTextField`を作成します。

このコンポーネントは`react-hook-form`の`Controller`を使用し、任意のフォームフィールドに適用できます。

エラーメッセージは、`errors`オブジェクトから取得され、ユーザーに適切なフィードバックを提供します。

```tsx:src/components/ValidationTextField/index.tsx

import { FormControl, TextField, TextFieldProps } from '@mui/material'
import { Control, Controller, FieldErrors, FieldValues, Path } from 'react-hook-form'

// このタイプは、ValidationTextFieldコンポーネントのプロパティを定義します。
type ValdarionTextFieldProps<T extends FieldValues> = {
  name: Path<T>
  helperText?: string
  control: Control<any>
  errors: FieldErrors<any>
} & TextFieldProps

export const ValidationTextField = <T extends FieldValues>({
  name,
  control,
  helperText,
  errors,
  ...rest
}: ValdarionTextFieldProps<T>) => {
  return (
    <Controller
      name={name} // フォーム内のフィールド名
      control={control} // react-hook-formのcontrolオブジェクト
      render={({ field }) => (
        <FormControl>
          <TextField
            name={field.name} // フィールド名
            value={field.value} // フィールドの値
            onChange={field.onChange} // 値が変更されたときのハンドラ
            onBlur={field.onBlur} // フォーカスが外れたときのハンドラ
            inputRef={field.ref} // フィールドへの参照
            disabled={field.disabled} // 無効化されているかどうか
            helperText={helperText} // 追加のヘルパーテキスト
            error={!!errors[name]} // エラーがあるかどうか
            {...rest} // その他のTextFieldプロパティ
          />
        </FormControl>
      )}
    />
  )
}
```

### 型の付け方について

`ValidationTextField`コンポーネントでは、ジェネリック型`T`を用いて、フォームフィールドの値の型を動的に指定します。これにより、さまざまなフォームデータ構造に柔軟に対応できるようになります。

型`ValdarionTextFieldProps<T>`は、必要なプロパティ（`name`, `control`, `errors`）と、オプショナルな`helperText`、さらに`TextFieldProps`からの追加プロパティを組み合わせています。

`Controller`コンポーネントの`render`プロパティは、実際のテキストフィールドとそのイベントハンドラを定義するために使用されます。

ここでの型指定は、TypeScriptを使用して安全性を高めるとともに、コンポーネントの再利用性を向上させる重要な役割を果たします。

## ステップ2: カスタムフックの作成

次に、フォームのロジックを処理するためのカスタムフック`useInputForm`を作成します。

このフックは、React Hook FormとZodを組み合わせて使用し、フォームの状態管理とバリデーションを効率的に行います。

```tsx:src/hooks/UseInputForm/hook.tsx

import { zodResolver } from '@hookform/resolvers/zod'
import { DefaultValues, FieldValues, useForm } from 'react-hook-form'
import { ZodSchema, z } from 'zod'

// この型は、useInputFormフックのプロパティを定義します。
type UseInputFormProps<T extends FieldValues> = {
  schema: ZodSchema<T> // Zodによるバリデーションスキーマ
  defaultValues?: DefaultValues<T> // フォームのデフォルト値
  mode?: 'onBlur' | 'onChange' | 'onSubmit' | 'onTouched' | 'all' | undefined // バリデーションモード
}

// useInputFormは、フォームのロジックをカプセル化したカスタムフックです。
export const useInputForm = <T extends FieldValues>({ schema, defaultValues, mode }: UseInputFormProps<T>) => {
  // useFormフックを使用して、フォームの状態とイベントハンドラを管理します。
  const {
    handleSubmit,
    control,
    formState: { errors },
    setError
  } = useForm<z.infer<typeof schema>>({
    mode, // バリデーションモード
    defaultValues, // デフォルト値
    resolver: zodResolver(schema) // Zodを使用したバリデーション
  })

  // カスタムフックは、フォームの状態と関数を返します。
  return {
    control,
    handleSubmit,
    formState: { errors },
    setError
  }
}
```

### フックの構造とロジック

カスタムフック`useInputForm`は、ジェネリック型`T`を利用して、様々なフォームデータ構造に適用できるようにしています。

このフックは、以下の主要なプロパティを持つ`UseInputFormProps`型の引数を受け取ります：

- `schema`: Zodによるバリデーションスキーマ。これにより、入力値のバリデーションルールを定義します。
- `defaultValues`: フォームのデフォルト値を設定します。これはオプショナルです。
- `mode`: バリデーションがトリガーされるタイミングを制御します（例：`onBlur`、`onChange`）。

`useForm`フックを使用して、以下の機能を提供します：

- `handleSubmit`: フォームの送信処理をハンドルする関数。
- `control`: フォームのフィールドをコントロールするためのオブジェクト。
- `errors`: フォームのバリデーションエラーを格納するオブジェクト。
- `setError`: 手動でフォームのエラーを設定する関数。

これらの機能により、フォームのデータ処理とバリデーションロジックを簡潔にし、複数のフォームコンポーネント間で簡単に共有できます。

### Zodとの統合

このフックでは、`zodResolver`を使用してZodスキーマをReact Hook Formに統合します。

`zodResolver`は、Zodスキーマを基にしてフォームデータのバリデーションを行い、エラーメッセージを提供します。

これにより、フォームのバリデーションロジックを別の場所で定義し、カスタムフック内で簡単に適用することができます。

このカスタムフックは、ZodとReact Hook Formの組み合わせにより、フォームのバリデーションロジックを簡潔に保ちながら、フォームの状態管理とバリデーションを効率的に扱うことができます。

## **ステップ3: バリデーションスキーマの定義**

バリデーションのために、`zod`を使用してメールアドレスのバリデーションスキーマを定義します。

```tsx:src/constants/emailMatchSchema.ts

import { z } from 'zod'

// 正規表現を使用して、有効な文字だけを含む文字列を定義します。
const pattern = /^[\u0021-\u007e]+$/u

// emailMatchSchemaは、メールアドレスのバリデーションルールを定義するオブジェクトです。
export const emailMatchSchema = {
  schema: z.object({
    email: z
      .string()
      .nonempty('メールアドレスが入力されていません。') //入力必須バリデーション
      .email('正しいメールアドレスを入力してください。') // メールアドレス形式のバリデーション
      .regex(pattern) // 追加の正規表現によるバリデーション
  }),
  defaultValues: { email: '' }, // フォームのデフォルト値
  names: { email: 'email' } // フォームフィールドの名前
}
```

### スキーマの構造

`emailMatchSchema`オブジェクトでは、以下の要素を定義します：

- `schema`: メールアドレスのバリデーションルールを含むZodスキーマ。これは、入力されたメールアドレスが特定の条件を満たしていることを保証します。
    - 必須フィールドの指定。
    - メールアドレス形式の確認。
    - 追加の正規表現によるカスタムバリデーションルール。
- `defaultValues`: フォームのデフォルト値を設定。ここでは空のメールアドレスフィールドを初期状態としています。
- `names`: フォーム内で使用されるフィールド名を定義。これにより、フィールド名の一貫性と再利用性を保証します。

### 正規表現の使用

スキーマ内で正規表現を使用しています。

この正規表現は、メールアドレスに特定の文字セットが含まれていることを確認するために使用されます。

これにより、メールアドレスの形式をさらに厳密に検証することができます。

## **ステップ4: フォームコンポーネントの作成**

カスタムフックとバリデーションスキーマを使用して、メールアドレスの存在をチェックするフォームコンポーネント`CheckExistEmail`を作成します。

### コンポーネントの構造と機能

`CheckExistEmail`コンポーネントでは、以下の機能を実装します：

- `useInputForm`カスタムフックを使用して、フォームの状態とバリデーションロジックを管理します。これには、メールアドレスのバリデーションスキーマ（`emailMatchSchema`）が含まれます。
- `ValidationTextField`コンポーネントを使用して、メールアドレスの入力フィールドを表示します。このフィールドは、エラーメッセージとバリデーションの状態をユーザーに提供します。
- `onSubmit`関数で、フォームの送信時のロジックを定義します。ここでは、入力されたメールアドレスがサンプルメールアドレスと一致するかどうかを確認し、一致しない場合はエラーを表示します。

```tsx:src/components/CheckExistEmail/index.tsx

import { Box, Button } from '@mui/material'
import React from 'react'
import { useInputForm } from '@/hooks/UseInputForm/hook'
import { z } from 'zod'
import { emailMatchSchema } from '@/constants/emailMatchSchema'
import { ValidationTextField } from '../ValidationTextField'

// サンプルメールアドレス
const sampleEmail = 'example@example.com'

export const CheckExistEmail = () => {

  // emailMatchSchemaからスキーマ、デフォルト値、フィールド名を取得します。
  const { schema, defaultValues, names } = emailMatchSchema

  // カスタムフックuseInputFormを使用して、フォームの状態管理とバリデーションを行います。
  const {
    handleSubmit,
    control,
    formState: { errors },
    setError
  } = useInputForm({ schema, defaultValues, mode: 'onBlur' })

  // エラーメッセージを取得します。
  const errorMessage = errors.email?.message as string

  // フォームの送信時の処理を定義します。
  const onSubmit = (data: z.infer<typeof schema>) => {
    // 入力されたメールアドレスがサンプルと一致しない場合、エラーを設定します。
    if (data.email !== sampleEmail) {
      setError('email', {
        type: 'manual',
        message: 'メールアドレスが見つかりません'
      })
    } else {
      // 一致する場合は、通常の処理（例：データベース確認、通知など）を行います。
				alert("メールアドレスが一致しました！")
    }
  }

  return (
    <Box component="form" onSubmit={handleSubmit(onSubmit)} display="flex" flexDirection="column" gap={3}>
      <ValidationTextField
        label="メールアドレス"
        name={names.email}
        control={control}
        errors={errors}
        helperText={errorMessage}
        placeholder="メールアドレスを入力してください"
      />
      <Button type="submit" variant="contained">Submit</Button>
    </Box>
  )
}
```

### フォームの送信処理

フォームの送信処理では、`handleSubmit`関数（`useInputForm`から提供される）を使用して、送信イベントをハンドルします。

`onSubmit`関数では、`ValidationTextField`を使用してユーザーにメールアドレス入力を求め、入力されたメールアドレスが特定の条件（この例ではサンプルのメールアドレスとの一致）を満たすかどうかを検証します。

一致しない場合は、`setError`関数を使用してカスタムエラーメッセージを設定します。

## **ステップ5: ページの作成**

最後に、作成したフォームコンポーネントを含むページ`CheckExistEmailPage`を作成します。

appディレクトリ配下のファイルは、配置されたディレクトリをパスとしてルーティングされます。

今回はappディレクトリ直下になるので、ルートパスで表示されるページとなります。

ページ内で`CheckExistEmail`を呼び出すだけのコンポーネントです。

```tsx:app/page.tsx

'use client'

import { CheckExistEmail } from '@/components/CheckExistEmail'
import React from 'react'

// CheckExistEmailPageは、メールアドレスチェックのための専用ページを定義するコンポーネントです。
const CheckExistEmailPage = () => {
  // CheckExistEmailコンポーネントをレンダリングします。
  // このコンポーネントは、ユーザーがメールアドレスを入力し、バリデーションを行うためのインターフェースを提供します。
  return <CheckExistEmail />
}

export default CheckExistEmailPage
```

ページとなるコンポーネントは、それ自体DOM構造の定義やロジックを含まず、ルーティングと呼び出すコンポーネントのレンダリングのみに責務を持たせる方が見通しよいと思います。

# **まとめ**

この記事では、`react-hook-form`、`zod`、そして`@mui/material`を組み合わせて、効率的かつ機能的なバリデーションフォームを作成する方法を解説しました。

極力ステップごとに分けて、分かりやすくなるよう心掛けたつもりです。

useStateやuseEffectを使った実装は重くなりがちですが、ライブラリを使うとこんなに簡単に実装できて素晴らしいと思います。（まだ理解が浅いため型付けに苦労しましたが、、、）

割と使いやすく、再利用性のあるコンポーネントとフックにできていると思いますので、ぜひ試してみてください。

## 参考にさせて頂いた記事

- [React Hook Form公式](https://react-hook-form.com/docs/useform#context)
- [React Hook Form+Zod+TypeScript+MUIの俺的コンポーネント化まとめ](https://qiita.com/kage1020/items/e6a6336132c83a0c3cef)
- [【React Hook Form × Zod × MUI】フォームを作ろう！](https://zenn.dev/monicle/articles/4868424f22d6f5)
- [MUI公式](https://mui.com/material-ui/react-text-field/)
- [Zod公式GitHub](https://github.com/colinhacks/zod)