react-hook-formを使ったフォームをコンポーネント分割する

## はじめに

`react` や `next.js` でフォーム作成を行う際、`react-hook-form` ライブラリは
有力な候補の一つになります。

しかし、例えば名前や趣味の入力欄をそれぞれコンポーネント分割しようと考えた際、
どこに何を定義すればいいのかを最初から判断して実装することはなかなか難しいと思います。

今回は簡単なフォームをコンポーネント分割に注目して実装することで、
`react-hook-form` に対する理解を深めていきましょう。


### この記事の対象者

フォーム作成のために `react-hook-form` を使いたいと思いつつ、
コンポーネント分割の仕方に悩んでいる方。

`next.js` でプロジェクトを作成して、簡単なフォーム作成を行います。
また、バリデーションチェックに `zod` ライブラリを導入しています。


### 前提

このあと利用する、各々のバージョン情報を記載しておきます。
| library | version |
|:-:|:-:|
| node | 18.17.1 |
| npm | 9.6.7 |
| next.js | 14.0.1 |
| react-hook-form | 7.48.1 |
| zod | 3.22.4 |
| @hookform/resolvers | 3.3.2 |



## 今回のプロジェクト構成

完成時、以下の構成になります。
　※主要ファイルのみを記載
　※折りたたみを開いてご確認ください

<details><summary>ディレクトリ構成</summary>

```
root
  └─src
      ├─app
      │      globals.css
      │      layout.tsx
      │      page.tsx
      │
      └─components
          ├─app
          │  │  index.ts
          │  │
          │  ├─Hobby
          │  │      formSchema.ts
          │  │      index.tsx
          │  │
          │  └─Name
          │          formSchema.ts
          │          index.tsx
          │
          └─parts
              │  index.ts
              │
              ├─Button
              │      index.tsx
              │
              └─InputText
                      index.tsx
```
</details>



## プロジェクトの作成＆下準備

それでは、プロジェクトを作成して必要なライブラリをインストールしましょう。


### プロジェクト作成

ローカルの任意のディレクトリで以下コマンドを実行します。
```terminal:terminal
npx create-next-app@latest
```

いくつか質問されますが、すべてデフォルトで大丈夫です。
（フォルダ名は自分の好きな名前に変えておきましょう）
```terminal:terminal
What is your project named? my-app
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
```


### 必要なライブラリのインストール

今回の主役である `react-hook-form` をインストールします。
このライブラリにより、フォームの値管理がとても楽になります。
今回詳しい説明はしないので、興味のある方は [公式サイト](https://react-hook-form.com/) の
Get Started タブの箇所だけでもざっと確認してみてください。
```terminal:terminal
npm install react-hook-form
```

次に、バリデーションチェックを楽にしてくれる `zod` をインストールします。
詳しく知りたい方は [公式ドキュメント](https://zod.dev/) をご確認ください。
```terminal:terminal
npm install zod
```

最後に、`react-hook-form` で `zod` を利用するために `@hookform/resolvers` をインストールします。
詳しく知りたい方は [npm ドキュメント](https://www.npmjs.com/package/@hookform/resolvers) を (ry。
```terminal:terminal
npm install @hookform/resolvers
```

これにて、必要なライブラリをすべてインストールすることが出来ました。


### 設定ファイルとcssファイルに変更を加える

このままではいくつかエラーが出てしまうので、設定ファイルに修正を加えます。
また、不要なcssを削除します。

<details><summary>.eslintrc.json</summary>

ESLintのデフォルト設定では関数に名前を付けない場合エラーを吐くので、
その機能をオフにします。
```diff_json:[root]\\.eslintrc.json
{
-  "extends": "next/core-web-vitals"
+  "extends": "next/core-web-vitals",
+  "rules": {
+    "react/display-name": 0
+  }
}
```
</details>

<details><summary>tsconfig.json</summary>

moduleResolution関係でエラーが出る場合は以下修正を加えてください。
[Qiita](https://qiita.com/yuk1_sys/items/598d0abacc15f984fb6b)にこのエラー内容を投稿しているので、気になった方はぜひご確認ください。
```diff_json:[root]\\.eslintrc.json
{
  "compilerOptions": {
     ・・・
-    "moduleResolution": "bundler",
+    "moduleResolution": "Node16",
     ・・・
  },
  ・・・
}
```
</details>

<details><summary>globals.css</summary>

不要なcssを削除します。
上3行は必要なので残しておいてください。
```diff_json:[root]\src\app\globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

- :root {
-   --foreground-rgb: 0, 0, 0;
-   --background-start-rgb: 214, 219, 220;
-   --background-end-rgb: 255, 255, 255;
- }
-
- @media (prefers-color-scheme: dark) {
-   :root {
-     --foreground-rgb: 255, 255, 255;
-     --background-start-rgb: 0, 0, 0;
-     --background-end-rgb: 0, 0, 0;
-   }
- }
-
- body {
-   color: rgb(var(--foreground-rgb));
-   background: linear-gradient(
-       to bottom,
-       transparent,
-       rgb(var(--background-end-rgb))
-     )
-     rgb(var(--background-start-rgb));
- }
```
</details>

<details><summary>.prettierrc (任意)</summary>

.prettierrcを設定しているので参考にどうぞ。
（ソースコードを整形する機能なので、なくても動作します）
```diff_json:[root]\\.prettierrc
{
  "printWidth": 120,
  "trailingComma": "es5",
  "tabWidth": 2,
  "semi": true,
  "singleQuote": true,
  "jsxSingleQuote": true,
  "endOfLine": "lf"
}
```
</details>

<details><summary>その他ファイル(任意)</summary>

プロジェクト生成時に自動で作成された、以下のファイルは削除してしまって大丈夫です。
* [root]\public\next.svg
* [root]\public\vercel.svg
* [root]\src\app\favicon.ico

また、`layout.tsx` の `metadata` は、今回用に文言を変更しておきましょう。
```diff_json:[root]\src\app\layout.tsx
export const metadata: Metadata = {
-  title: 'Create Next App',
-  description: 'Generated by create next app',
+  title: 'react-hook-form',
+  description: "Let's separate components while using react-hook-form!",
}
```
</details>

<br />

以上で下準備はOKです。



## 基礎となる部品を作成する

まずは基本パーツを作っていきます。
今回はButtonコンポーネントと、ユーザがテキストを入力するための
InputTextコンポーネントを作成します。


### Button

`src\components\parts\Button` ディレクトリの下にコンポーネントを作成します。
このコンポーネントは、`button` タグが持つデフォルトの属性をプロパティに持ち、
また `children` には `String` 型のみを受け付けます。

<details><summary>サンプルコード</summary>

```react:src\components\parts\Button\index.tsx
import React, { forwardRef } from 'react';

type BaseProps = {
  children?: string;
};

type Props = BaseProps & Omit<React.ComponentPropsWithoutRef<'button'>, keyof BaseProps | 'className' | 'style'>;

export const Button = forwardRef<HTMLButtonElement, Props>(
  ({ children = 'ボタン', type = 'button', ...buttonAttributes }, ref) => {
    return (
      <button
        ref={ref}
        type={type}
        className='flex justify-center items-center w-full h-12 rounded-3xl
                  text-base text-white disabled:text-gray-100
                  bg-blue-500 disabled:bg-gray-300
                  duration-300
                  enabled:hover:opacity-50'
        {...buttonAttributes}
      >
        {children}
      </button>
    );
  }
);
```
</details>


### InputText

`src\components\parts\InputText` ディレクトリの下にコンポーネントを作成します。
このコンポーネントは、`input` タグが持つデフォルトの属性と、
エラーメッセージ用の `errMsg`、ラベルテキスト用の `label` を プロパティに持ちます。

<details><summary>サンプルコード</summary>

```react:src\components\parts\InputText\index.tsx
import { forwardRef } from 'react';

type BaseProps = {
  label: string;
  errMsg?: string;
};

type Props = BaseProps & Omit<React.ComponentPropsWithoutRef<'input'>, keyof BaseProps | 'className' | 'style'>;

export const InputText = forwardRef<HTMLInputElement, Props>(({ label, errMsg, ...inputAttributes }, ref) => {
  return (
    <div className='flex flex-col gap-1'>
      <label htmlFor={label}>{label}</label>
      <div>
        {errMsg && <p className='text-sm text-red-500'>{errMsg}</p>}
        <input
          type='text'
          ref={ref}
          id={label}
          className='flex w-full p-3 rounded bg-white
                border border-solid border-gray-200
                focus:outline-none'
          {...inputAttributes}
        />
      </div>
    </div>
  );
});
```
</details>


### インデックスファイル

ButtonコンポーネントとInputTextコンポーネントを取得するための
インデックスファイルを作成します。

<details><summary>サンプルコード</summary>

```react:src\components\parts\index.tsx
import { Button } from './Button';
import { InputText } from './InputText';

export { Button, InputText };
```
</details>



## コンポーネント分割を意識して画面を作成する

いよいよ `react-hook-form` に触れていきます。

`page.tsx` に画面の中身を実装しますが、名前の入力フォームや趣味の入力フォームは
コンポーネント分割して別ファイルに実装していきます。

### page.tsxを作成する
まずは `page.tsx` の実装を行います。
`@/components/app/index` からインポートしている `Hobby` と `Name` は後ほど実装します。

ここで注目したいのは、

**`FormProvider` でフォームを囲うことで、コンポーネント分割した `Hobby` や `Name` 内でも
`useForm` から取得した共通の `methods` を使えるようになる**

ということです。

<details><summary>サンプルコード</summary>

```react:src\app\page.tsx
'use client';

import { Hobby, Name } from '@/components/app/index';
import { Button } from '@/components/parts';
import { FormProvider, useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback } from 'react';

// フォームに入力される値すべての型とバリデーションチェックを定義するフォームスキーマ
const formSchema = z.object({
  name: Name.formSchema,
  hobby: Hobby.formSchema,
});

// フォームスキーマから型定義のみを抽出
type Form = z.infer<typeof formSchema>;

const Page = () => {
  const methods = useForm<Form>({
    mode: 'onChange',
    // resolverに上で定義したformSchemaをセットすることで、
    // zodで定義したバリデーションチェックが機能するようになる
    resolver: zodResolver(formSchema),
  });

  // Submitされた場合、フォームの入力値をリセットする
  const onSubmit = useCallback(() => {
    methods.reset({
      name: {
        lastName: '',
        firstName: '',
      },
      hobby: { hobby: '' },
    });
  }, [methods]);

  return (
    <div className='flex flex-col gap-8 py-10'>
      <h1 className='flex justify-center text-2xl font-bold'>{'react-hook-formを学ぼう！'}</h1>
      <FormProvider {...methods}>
        <form onSubmit={methods.handleSubmit(onSubmit)}>
          <div className='flex flex-col gap-8 max-w-lg px-5 m-auto'>
            <div className='flex flex-col gap-4'>
              <Name.Component />
              <Hobby.Component />
            </div>
            <Button type='submit' disabled={!methods.formState.isValid}>
              リセット
            </Button>
          </div>
        </form>
      </FormProvider>
    </div>
  );
};

export default Page;
```
</details>


### インデックスファイル

あらかじめ、名前入力用フォームと趣味入力用フォームのインデックスファイルを
作成しておきます。

<details><summary>サンプルコード</summary>

```react:src\components\app\index.ts
import * as Name from './Name';
import * as Hobby from './Hobby';

export { Name, Hobby };

```
</details>


### 名前入力用フォーム

名前入力用のフォームを作成します。
（`page.tsx` の `Name` に該当）

`useFormContext` を使って `register` やエラー情報を取得しますが、
返り値の型は名前入力用フォームで使用する型に限定させましょう。

<details><summary>サンプルコード</summary>

```react:src\components\app\Name\formSchema.ts
import { z } from 'zod';

// 名前入力用フォームで入力される値の型とバリデーションチェックを定義するフォームスキーマ
export const formSchema = z.object({
  lastName: z.string().min(1, '入力必須です').max(20, '20字以内で入力ください'),
  firstName: z.string().min(1, '入力必須です').max(20, '20字以内で入力ください'),
});

// フォームスキーマから型定義のみを抽出
export type Form = z.infer<typeof formSchema>;
```

```react:src\components\app\Name\index.tsx
import { InputText } from '@/components/parts';
import { useFormContext } from 'react-hook-form';
import { formSchema, Form } from './formSchema';

export { formSchema };

export const Component = () => {
  const {
    register,
    formState: { errors },
  } = useFormContext<Record<'name', Form>>();

  return (
    <div className='flex flex-col gap-4'>
      <InputText
        label='苗字'
        errMsg={errors?.name?.lastName?.message}
        placeholder='鈴木'
        {...register('name.lastName')}
      />
      <InputText
        label='名前'
        errMsg={errors?.name?.firstName?.message}
        placeholder='太郎'
        {...register('name.firstName')}
      />
    </div>
  );
};
```
</details>


### 趣味入力用フォーム

名前入力用フォームと同じように趣味入力用フォームも作成します。
（`page.tsx` の `Hobby` に該当）

<details><summary>サンプルコード</summary>

```react:src\components\app\Hobby\formSchema.ts
import { z } from 'zod';

// 趣味入力用フォームで入力される値の型とバリデーションチェックを定義するフォームスキーマ
export const formSchema = z.object({
  hobby: z.string().min(1, '入力必須です').max(50, '50字以内で入力ください'),
});

// フォームスキーマから型定義のみを抽出
export type Form = z.infer<typeof formSchema>;
```

```react:src\components\app\Hobby\index.tsx
import { InputText } from '@/components/parts';
import { useFormContext } from 'react-hook-form';
import { formSchema, Form } from './formSchema';

export { formSchema };

export const Component = () => {
  const {
    register,
    formState: { errors },
  } = useFormContext<Record<'hobby', Form>>();

  return (
    <InputText
      label='趣味'
      errMsg={errors?.hobby?.hobby?.message}
      placeholder='野球、サッカー'
      {...register('hobby.hobby')}
    />
  );
};
```
</details>



## 完成

上記を実装できたら、ぜひ `npm run dev` を実行して動作確認してみてください。

キャプチャのような画面がブラウザで表示され、
簡単なバリデーションチェックが可能なフォームを確認できると思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3581713/5c58be80-628e-1a46-69cc-238db92c06ce.png)



## まとめ

まとめとして、今までの内容を超コンパクトにしたものを記載しておきます。
zod等の実装がない分とても見やすいです。

```react:呼び出し元（page.tsxに該当）
'use client';

import { FormProvider, useForm } from 'react-hook-form';

export type Form = {
  // フォームに入力される値において、すべての型を定義
  name: string,
  hobby: string,
};

const Page = () => {
  // フォームの値管理に必要なオブジェクトを取得
  const methods = useForm<Form>({
    mode: 'onChange',
  });

  return <FormProvider {...methods}>{/* フォーム実装 */}</FormProvider>;
};

export default Page;
```

```react:呼び出し先（Name、Hobbyに該当）
import { Form } from '@/app/page';
import { useFormContext } from 'react-hook-form';

export const Component = () => {
  const { register } = useFormContext<Form>();

  return (
    <input
      className='border'
      {...register('name', {
        // バリデーションチェック実装 (zodを利用しない場合はここに記述する)
        required: {
          value: true,
          message: '入力が必須の項目です。',
        },
      })}
    />
  );
};
```



## 締め

`react-hook-form`を利用したフォームのコンポーネント分割について、
理解を深めることはできたでしょうか？

この記事がだれかのお役に立てたら幸いです。