【Next.js】react-hook-formとzodを用いたフォームを作ろう

# はじめに
こんにちは、H×Hのセンリツ大好きエンジニアです。（同担OKです。）
今回はタイトル通りフォームを作っていきます。
`email`と`password`、`confirmPassword`を使用したサインアップフォームを想定しています。

間違えてる箇所があるかも知れませんが、生暖かい目で見守りつつ教えて下さい。

## 使用するライブラリ

### react-hook-form
フォームのパフォーマンスを向上させ、コードの行数を減らすことを目的としたライブラリです。これにより、不要なリレンダリングを減らし、ユーザー入力を効率的に収集・管理することができます。

### zod
TypeScriptでの型安全なデータバリデーションを可能にするライブラリです。zodを使用すると、フォームの入力値に対して厳格な型チェックを行い、開発者が期待するデータ構造を保証できます。

# 実装
## Zodを用いたバリデーションスキーマの作成
``` SignUpFormSchema.ts
import { z } from 'zod'

export const SignUpFormSchema = z
  .object({
    email: z
      .string()
      .min(1, { message: 'メールアドレスを入力して下さい。' })
      .email({ message: 'メールアドレスの形式で入力して下さい。' })
      .max(100, { message: 'メールアドレスの形式で入力して下さい。' }),
    password: z
      .string()
      .min(1, { message: 'パスワードを入力して下さい。' })
      .max(100, { message: 'パスワードは100文字以内で入力して下さい。' }),
    confirmPassword: z
      .string()
      .min(1, { message: 'パスワードを再入力して下さい。' })
      .max(100, { message: 'パスワードは100文字以内で入力して下さい。' })
  })
  .superRefine((data, ctx) => {
    if (data.password !== data.confirmPassword) {
      ctx.addIssue({
        message: 'パスワードが異なります。',
        path: ['confirmPassword'],
        code: z.ZodIssueCode.custom
      })
    }
  })

export type SignUpFormType = z.infer<typeof SignUpFormSchema>

```
このファイルでは、以下のことを行なっています。
- バリデーションに`email`と`password`、そして`confirmPassword`フィールドを設定
- それぞれに対して最小文字数、最大文字数の検証ルールを適用
    - `email`はメールアドレス形式のルールも追加
- `superRefine`メソッドを使用して、passwordとconfirmPasswordが一致しているかどうかのカスタムバリデーションを適用

ここで定義したバリデーションスキーマを、react-hook-formに適用させます。

``` SignUpPage.tsx
'use client'

import { SignUpFormSchema, SignUpFormType } from '@/schemas/SignUpFormSchema'
import { useForm, FormProvider } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'

export const SignUpPage = () => {
  const methods = useForm<SignUpFormType>({
    mode: 'onChange',
    reValidateMode: 'onChange',
    resolver: zodResolver(SignUpFormSchema),
    defaultValues: {
      email: '',
      password: '',
      confirmPassword: ''
    }
  })

  const {
    register,
    formState: { errors },
    handleSubmit
  } = methods
```
ここでは、バリデーションスキーマを適用しつつ、フォームの状態を管理するための設定を行います。
- `react-hook-form`の`useForm`フックを使用
    - フォームの状態管理とバリデーションを簡単にするためのフック
- `mode: 'onChange'`と`reValidateMode: 'onChange'`でバリデーションタイミングをフォームの値が変更される度に行う
- `resolver: zodResolver(SignUpFormSchema)`で`react-hook-form`のバリデーションロジックに先ほど追加したカスタムバリデーションリゾルバを設定
- 初期値設定（全て空）
- `useForm`フックから得られた`methods`オブジェクトを分割代入

最後に、フォームのUIとSubmit時の挙動を作っていくう！（魚捌きおじさん風）
UI部分はChakraUIを採用しています。（あまりCSSを書きたくないため）

``` SignUpPage.tsx
import { Button, Container } from '@chakra-ui/react'
import { FormInput } from '@/components/atoms/FormInput'
import { SignUp } from '@/api/user'
import { STATUS_CODE } from '@/const'

interface FormData {
  email: string
  password: string
  confirmPassword: string
}

export const SignUpPage = () => {

~~~~~ 先ほどの続き ~~~~~

  const onSubmit = async (params: FormData) => {
    console.log(params)
  }

  return (
    <Container size="md">
      <FormProvider {...methods}>
        <form onSubmit={handleSubmit(onSubmit)}>
          <FormInput
            type="email"
            register={register('email')}
            label="メールアドレス"
            placeholder="example@example.com"
            errMessage={errors.email?.message}
          />
          <FormInput
            type="password"
            register={register('password')}
            label="パスワード"
            placeholder="パスワードを入力"
            errMessage={errors.password?.message}
          />
          <FormInput
            type="password"
            register={register('confirmPassword')}
            label="確認用パスワード"
            placeholder="パスワードを再入力"
            errMessage={errors.confirmPassword?.message}
          />
          <Button type="submit">
            新規登録
          </Button>
        </form>
      </FormProvider>
    </Container>
  )
}
```

コードの解説は以下の通りです。
- `onSubmit`は、フォームが送信されるタイミングで呼び出され、`SignUp`APIを非同期で呼び出す
    - `SignUp`は以下のように作成していますが、よしなに作って下さい。
        - 引数：`email`、`password`
        - 返り値：`status`、`data`
    - `status`によって挙動を変更
        - モーダルを使って送信成功、失敗のUIを呼び出すようにしても良いかと思います。
- `<FormProvider {...methods}>`は、`react-hook-form`の`FormProvider`を使用して、フォームの状態管理に必要なmethodsオブジェクトをフォーム内の全てのコンポーネントに渡す
    - これにより、フォーム内のどのコンポーネントからもフォームの状態や機能にアクセスできる
- `<form onSubmit={handleSubmit(onSubmit)}>`は、フォームの`onSubmit`イベントを`handleSubmit`関数に委譲
    - フォームが送信される前に`react-hook-form`によるバリデーションが行われ、バリデーションをパスしたデータのみが`onSubmit`関数に渡される
- `<FormInput ... />`コンポーネントは、メールアドレス、パスワード、確認用パスワードの各入力フィールドをレンダリング
    - `react-hook-form`の`register`関数を使用してフォームの状態管理とバリデーションに統合される

`FormInput`は自分が作ったコンポーネントなので、以下に実装を書いておきますが、ChakraUIの`Input`コンポーネントをそのまま使用しても良いと思います。
``` FormInput.tsx
import { Box, FormControl, FormErrorMessage, Input } from '@chakra-ui/react'
import { HTMLInputTypeAttribute } from 'react'
import { UseFormRegisterReturn } from 'react-hook-form'

interface FormInputProps {
  label: string
  type?: HTMLInputTypeAttribute
  placeholder?: string
  register?: UseFormRegisterReturn
  errMessage?: string
}

export const FormInput = (props: FormInputProps) => {
  const { label, type = 'text', placeholder, register, errMessage } = props
  return (
    <Box>
      <FormControl isInvalid={!!errMessage}>
        {label}
        <Input type={type} {...register} placeholder={placeholder} />
        {!!errMessage && <FormErrorMessage>{errMessage}</FormErrorMessage>}
      </FormControl>
    </Box>
  )
}

```

以上で、フォームの作成が完了しました。
動作確認してみます。見た目にはツッコミを入れないでください。
![スクリーンショット 2024-03-03 22.06.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653241/8280795b-8a08-9b8d-edd5-aa4530ed37f7.png)


初期状態では、placeholderのみ表示されています。
バリデーションに引っ掛かるようにフォームに入力してみます。
![スクリーンショット 2024-03-03 22.06.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653241/b20ece5f-2845-6b2f-356b-1ea41603b1e5.png)
このように、きちんとバリデーションチェックが行われているのがわかります。

次に、適当な値を入れて送信してみます。
![スクリーンショット 2024-03-03 22.12.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2653241/edc9de04-ee31-8ea9-66d5-4169fca63fa2.png)

コンソールに、入力した値が正しく表示されていることが分かります。
これでいとも簡単にフォームを錬金できましたね🎵

最終的なコードです。
``` SignUpPage.tsx
'use client'

import { SignUpFormSchema, SignUpFormType } from '@/schemas/SignUpFormSchema'
import { useForm, FormProvider } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Button, Container } from '@chakra-ui/react'
import { FormInput } from '@/components/atoms/FormInput'

export const SignUpPage = () => {
  const methods = useForm<SignUpFormType>({
    mode: 'onChange',
    reValidateMode: 'onChange',
    resolver: zodResolver(SignUpFormSchema),
    defaultValues: {
      email: '',
      password: '',
      confirmPassword: ''
    }
  })

  const {
    register,
    formState: { errors },
    handleSubmit
  } = methods

  const onSubmit = async (params: FormData) => {
    console.log(params)
  }

  return (
    <Container size="md">
      <FormProvider {...methods}>
        <form onSubmit={handleSubmit(onSubmit)}>
          <FormInput
            type="email"
            register={register('email')}
            label="メールアドレス"
            placeholder="example@example.com"
            errMessage={errors.email?.message}
          />
          <FormInput
            type="password"
            register={register('password')}
            label="パスワード"
            placeholder="パスワードを入力"
            errMessage={errors.password?.message}
          />
          <FormInput
            type="password"
            register={register('confirmPassword')}
            label="確認用パスワード"
            placeholder="パスワードを再入力"
            errMessage={errors.confirmPassword?.message}
          />
          <Button type="submit">
            新規登録
          </Button>
        </form>
      </FormProvider>
    </Container>
  )
}
```


## おまけ（送信ボタンの活性/非活性化）
`react-hook-form`の`watch`または`useWatch`と`getFieldState`を使って、フォームの値が正常な場合のみ送信ボタンが活性化するように実装します。

``` SignUpPage.tsx
~~~ 省略 ~~~
  const {
    register,
    formState: { errors },
    handleSubmit,
    getFieldState,
    control
  } = methods

  const watchEmail = useWatch({
    control,
    name: 'email'
  })

  const watchPassword = useWatch({
    control,
    name: 'password'
  })

  const watchConfirmPassword = useWatch({
    control,
    name: 'confirmPassword'
  })

  // 必須入力の項目が全て正しく入力されているかチェック
  const isDisabled = (): boolean => {
    let isDisabled = false
    if (getFieldState('email').invalid || !watchEmail) {
      isDisabled = true
    }
    if (getFieldState('password').invalid || !watchPassword) {
      isDisabled = true
    }
    if (getFieldState('confirmPassword').invalid || !watchConfirmPassword) {
      isDisabled = true
    }
    return isDisabled
  }

~~~ 省略 ~~~
        <Button type="submit" isDisabled={isDisabled()}>
            新規登録
          </Button>
```
このように、`methods`から追加で`getFieldState`と`control`を分割代入します。
そして、各フォームの監視用関数を定義し値が入っていない場合もしくはバリデーションチェックに引っ掛かっている場合Falseを返す関数を作成します。

（`watch`を使うともう少し短く書けますが、`watch`はフォーム全体に再レンダリングが走ってしまうのでパフォーマンス的によろしくないです。）

その関数を、ボタンコンポーネントの`isDisabled`に渡すと実装できます。

# さいごに
ここまで読んでいただき感謝です。
いえ、感謝では足りません。嬉しいYummy感謝感謝🎵です。（真顔）

何か不明な点や改善点等ありましたら気軽にコメントしていただけると幸いです。
それではごきげんよう、センリツでした。