Chakra UIとreact-hook-formでフォームを作成してみた

## はじめに

この記事は、[ミロゴス Advent Calendar 2023](https://qiita.com/advent-calendar/2023/milogos) 10日目の投稿です。
本記事では、Chakra UIとreact-hook-formを活用したフォームの作成について紹介できればと思います。

記事の内容は、フォームの基本的な作成手順から、バリデーション処理の追加、さらには画像を使ったラジオボタンの作成まで網羅的に解説します。

## react-hook-formとは

react-hook-formは、Reactアプリケーションでフォームを簡単に作成し、状態管理やバリデーションを行うためのライブラリです。
これは、Reactフック（Hooks）を使用してフォームの状態を効果的に管理することを目的としています。

## 内容

### 環境構築

以下の記事で紹介しているため、こちらをご参照ください。

https://qiita.com/tk77/private/8f18d272437630b6d17c#%E7%92%B0%E5%A2%83%E6%A7%8B%E7%AF%89

#### パッケージの追加

- react-hook-form のインストール

```bash
npm install react-hook-form
```

- chakra-ui/icons のインストール

```bash
npm install @chakra-ui/icons
```

### 画像付きラジオボタンの作成

このセクションでは、通常のラジオボタンを画像に変更し、選択した画像の ID を取得できるようにします。
以下が手順になります。

#### 画像の用意

まず、必要な画像を用意し、それらをプロジェクトのpublic/imagesディレクトリに配置します。
```
.
├── README.md
├── next-env.d.ts
・・・
├── public
│   └── images
│       ├── image_1.png
│       ├── image_2.png
│       └── image_3.png
・・・
```
#### 定数の定義

別ファイル (constants.ts) に必要な定数を定義します。
これには画像のタイトルやパス、バリデーションメッセージなどが含まれます。

```ts
export const TITLE_FIRST = 'タイトル1';
export const TITLE_SECOND = 'タイトル2';
export const TITLE_THIRD = 'タイトル3';
export const IMAGE_PATH_FIRST = '/images/image_1.png';
export const IMAGE_PATH_SECOND = '/images/image_2.png';
export const IMAGE_PATH_THIRD = '/images/image_3.png';
export const IMAGE_ID_VALIDATION_MESSAGE = '画像を選択してください。';
export const MESSAGE_FIELD_VALIDATION_MESSAGE =
  'メッセージを入力してください。';
export const MESSAGE_FIELD_MAX_LENGTH = 100;
export const MESSAGE_FIELD_MAX_LENGTH_VALIDATION_MESSAGE =
  'メッセージは100文字以内で入力してください。';
export const SUBMIT_BUTTON_TEXT = '送信';
```

#### 画像の一覧表示

画像一覧を表示するためのコンポーネントを作成します。

```ts
import { Box, Flex, Heading, Image, Text, VStack } from "@chakra-ui/react";
import React from "react";
import * as FormConstants from "./constants";

const IndexPage = () => {
  const images = [
    {
      id: 1, // 画像ID
      title: FormConstants.TITLE_FIRST, // 画像タイトル
      src: FormConstants.IMAGE_PATH_FIRST, // 画像パス
    },
    {
      id: 2,
      title: FormConstants.TITLE_SECOND,
      src: FormConstants.IMAGE_PATH_SECOND,
    },
    {
      id: 3,
      title: FormConstants.TITLE_THIRD,
      src: FormConstants.IMAGE_PATH_THIRD,
    },
  ];

  return (
    <>
      <Flex alignItems="center" justifyContent="center">
        <VStack spacing="4" align="start" padding="0">
          <VStack align="start">
            <Heading size="lg">画像の選択</Heading>
            <Heading fontSize="14px">
              画像をクリックすると対象の画像IDを取得できます。
            </Heading>
          </VStack>
          {images.map((image, index) => (
            <React.Fragment key={index}>
              <Text>{image.title}</Text>
              <Box
                position="relative"
                key={image.id}
                borderRadius="md"
                marginBottom={index === images.length - 1 ? "30px" : "0"}
              >
                <Image src={image.src} alt={`Image ${image.id}`} width="100%" />{" "}
              </Box>
            </React.Fragment>
          ))}
        </VStack>
      </Flex>
    </>
  );
};
```
このコンポーネントでは、images配列に含まれる画像情報をもとに画像を一覧表示しています。
各画像に対して、画像IDを付与しています。

以下の画面が表示されればOKです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952491/3471e7ca-8b78-e0c3-3220-33afc8593388.png)


##### Flex コンポーネント

Flexコンポーネントは、親要素と子要素を操作して、柔軟でレスポンシブなレイアウトを提供します。

- alignItems="center"

  - alignItemsプロパティは、子要素を垂直方向に中央揃えに設定しています。

- justifyContent="center"

  - justifyContentプロパティは、子要素を水平方向に中央揃えに設定しています。

##### VStack コンポーネント

VStackコンポーネントは、垂直方向（縦方向）に子要素を配置するための Chakra UIのコンポーネントです。

- spacing="4":

  - spacingプロパティは、子要素間の垂直方向の間隔を指定します。この例では、4の間隔が設定されています。

- align="start":

  - alignプロパティは、子要素の水平方向の配置を指定します。"start"は左寄せを意味します。子要素は左端に揃えられます。

##### Box コンポーネント

他の要素やコンポーネントを包み込んで、スタイルやプロパティを設定するために使用されます

- position="relative

  - 要素に対して相対的な位置を指定します。

- borderRadius="md

  - 角を丸くします。

- marginBottom={index === images.length - 1 ? "30px" : "0"}
  - 三項演算子が使用されています。画像が配列内で最後の要素である場合、マージンは"30px"に設定されます。

##### React.Fragment

視覚的な要素を生成せずに複数の要素をまとめるためのものです。
見た目には何も表示しない空のコンポーネントであり、主に複数の要素をラップするために使います。


#### 選択した画像IDの取得

ユーザーが画像をクリックしたときに、その画像IDを取得するためには、以下の手順を実装します。

- 画像をクリックする関数の指定
```ts
images.map((image, index) => (
  <React.Fragment key={index}>
    <Text>{image.title}</Text>
    <Box
      position="relative"
      key={image.id}
      borderRadius="md"
      onClick={() => {
        // クリックされた際にsetSelectedImageId関数を呼び出します
        setSelectedImageId(image.id);
      }}
      marginBottom={index === images.length - 1 ? "30px" : "0"}
    >
      <Image src={image.src} alt={`Image ${image.id}`} width="100%" />
    </Box>
  </React.Fragment>
));
```
- 選択された画像IDを取得する状態管理の初期化
```ts
import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';

・・・

// selectedImageIdを初期化
const [selectedImageId, setSelectedImageId] = useState(null as number | null);
// コンソールに出力
console.log("選択した画像ID", selectedImageId);
```
これにより、ユーザーが画像をクリックすると、setSelectedImageId関数が呼び出され、選択された画像のIDがselectedImageIdステートに設定されます。
この値はコンソールにも出力され、選択した画像IDを確認することができます。

##### useState フック

useStateフックは、初期状態を引数に取り、現在の状態とその状態を更新するための関数を返します。

- const [selectedImageId, setSelectedImageId] = useState(null as number | null);
  - useStateフックを使用して、selectedImageIdという状態変数を宣言しています。これは現在選択されている画像のIDを保持します。

> [useState](https://ja.react.dev/reference/react/useState)
>
> useStateは、コンポーネントにstate変数を追加するためのReactフックです。

#### 選択した画像にチェックアイコンの付与

選択した画像にチェックのアイコンを付与するコードは、以下の通りです。

```ts
images.map((image, index) => (
  <Box
    key={image.id}
    position="relative"
    borderRadius="md"
    onClick={() => {
      setSelectedImageId(image.id);
    }}
    marginBottom={index === images.length - 1 ? "30px" : "0"}
  >
    <Text>{image.title}</Text>
    <Image
      src={image.src}
      alt={`Image ${image.id}`}
      width="100%"
      // 三項演算子を使用して、選択された画像かどうかに応じてopacityを変更
      opacity={selectedImageId === image.id ? 1 : 0.6}
    />
    // 選択された画像にCheckIconを表示
    {selectedImageId === image.id && (
      <CheckIcon
        position="absolute"
        top="5px"
        right="5px"
        w={6}
        h={6}
        color="green.400"
      />
    )}
  </Box>
));
```
このコードでは、opacityプロパティを三項演算子を使用して、選択された画像かどうかによって切り替えています。
また、選択された画像にはCheckIconを表示しています。
これにより、ユーザーが画像をクリックすると、選択された画像にはチェックのアイコンが表示されます。

- opacity={selectedImageId === image.id ? 1 : 0.6}

  - 選択された画像の場合はopacityを1（不透明）にし、それ以外の場合は0.6（60%透明）にします。

- {selectedImageId === image.id && ( ・・・)}
  - 選択された画像の場合にのみ CheckIcon を表示します。

以下のように表示されればOKです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952491/a2982208-be9a-92a1-c650-959543317f24.png)

### フォームの作成
 
このセクションでは、ボタンを押下することでメッセージと画像IDを取得できるフォームを構築します。
以下がそのコードです。
```ts
const {
  register,
  handleSubmit,
  setValue,
} = useForm();

// useEffectを使用して、selectedImageIdが変更されたときにフォームの値の更新
useEffect(() => {
  setValue('imageId', selectedImageId);
}, [selectedImageId, setValue]);

const onSubmit = (data) => {
  const imageId = data.imageId;
  const message = data.message;

  // 取得した画像IDとメッセージをコンソールに出力
  console.log(message);
  console.log(imageId);
};

return (
  <>
    ・・・
    <Flex alignItems="center" justifyContent="center">
      {/* フォームの送信時に実行される関数を指定 */}
      <form onSubmit={handleSubmit(onSubmit)}>
        <VStack align="start" marginBottom="20px">
          <Heading size="lg">メッセージの入力</Heading>
          <Heading fontSize="14px">入力された内容を取得します。</Heading>
        </VStack>
        <Textarea
          // react-hook-form の register メソッドを使用してフォームフィールドを登録
          {...register('message')} 
          height="200px"
          border="2px solid black"
          marginBottom="20px"
        />
        <Button
          type="submit"
          width="100%"
          backgroundColor="black"
          color="white"
        >    
          {FormConstants.SUBMIT_BUTTON_TEXT}
        </Button>
      </form>
    </Flex>
  </>
);
```
このコードでは、react-hook-formを使用してフォームの状態を管理し、useEffectフックを利用して selectedImageIdの変更時にフォームの値を更新しています。
そして、フォームが送信された際には指定された関数が実行され、メッセージと画像IDがコンソールに出力されるようになっています。

ボタンを押下したときに、以下のような表示をされていればOKです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952491/8cb32de8-c171-124a-c638-57045bedba40.png)


##### useForm フック

useFormは、react-hook-formライブラリで提供されるフックの一つで、Reactアプリケーション内でフォームの状態やバリデーションなどを簡単に管理するために使用されます。

- const { setValue } = useForm();
  - setValueは、react-hook-formライブラリで提供されるuseFormフックから取得できるメソッドの一つです。
    第一引数に入れた変数に第二引数に入れた値をセットする関数となります。

> [useForm](https://react-hook-form.com/docs/useform)
>
> useForm is a custom hook for managing forms with ease. It takes one object as optional argument. The following example demonstrates all of its properties along with their default values.

##### useEffect フック

useEffect は、React コンポーネントがレンダリングされた後に非同期の操作や副作用を実行するためのものです。

- useEffect(() => {
  setValue("imageId", selectedImageId);
  }, [selectedImageId, setValue]);
  - このuseEffectブロックは、selectedImageIdの値が変更されたときに実行されます。そして、setValue("imageId", selectedImageId)を実行しています。

> [useEffect](https://ja.react.dev/reference/react/useEffect)
>
> useEffectは、コンポーネントを外部システムと同期させるためのReactフックです。


#### バリデーションの作成
このコードは、フォーム内のラジオボタンとメッセージフィールドに対するバリデーションの作成を示しています。

##### ラジオボタンのバリデーション
このコードでは、ラジオボタンの選択が必須であることをバリデーションしています。

```ts
<FormControl isInvalid={!!errors.imageId}>
  {/* react-hook-form の Controller を使用してフォームフィールドを制御 */}
  <Controller
    name="imageId"
    control={control}
    defaultValue=""
    rules={{
      required:
        FormConstants.IMAGE_ID_VALIDATION_MESSAGE,
    }}
    render={({ field }) => (
      <input
        type="hidden"
        value={field.value || ''}
        onChange={field.onChange}
      />
    )}
  />
  {/* もしエラーメッセージがあれば表示 */}
  {typeof errors.imageId?.message === 'string' && (
    <FormErrorMessage>{errors.imageId.message}</FormErrorMessage>
  )}
</FormControl>
```
このコードは、フォーム内で画像ラジオボタンの選択が必須であり、未選択の場合にエラーメッセージを表示します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952491/6bf0b74b-5b17-4eca-cac6-1768c3e731f8.png)

##### メッセージフィールドのバリデーション
このコードでは、メッセージフィールドに対するバリデーションを実現しています。

```ts
<FormControl isInvalid={!!errors.message} width='500px'>
  {/* エラーメッセージが存在する場合に表示 */}
  {typeof errors.message?.message === 'string' && (
    <FormErrorMessage>{errors.message.message}</FormErrorMessage>
  )}
  <VStack align="start" marginBottom="20px">
    <Heading size="lg">メッセージの入力</Heading>
    <Heading fontSize="14px">入力された内容を取得します。</Heading>
  </VStack>
  <Textarea
    {...register('message', {
      // 必須入力のバリデーションルール
      required:
        FormConstants.MESSAGE_FIELD_VALIDATION_MESSAGE,
      // 最大文字数のバリデーションルール
      maxLength: {
        value: FormConstants.MESSAGE_FIELD_MAX_LENGTH,
        message:
          FormConstants.MESSAGE_FIELD_MAX_LENGTH_VALIDATION_MESSAGE,
      },
    })}
    height="200px"
    border="2px solid black"
    marginBottom="20px"
  />
  <Button
    type="submit"
    width="100%"
    backgroundColor="black"
    color="white"
  >
    {FormConstants.SUBMIT_BUTTON_TEXT}
  </Button>
</FormControl>
```
このコードは、メッセージが必須であり、最大文字数が設定されている場合に、それに対するバリデーションを行います。
エラーがある場合はエラーメッセージを表示します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952491/785097eb-9e6c-b002-6361-3f854f749f33.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2952491/d219642d-efdb-49fd-d2b7-c34184e73e8b.png)

##### FormControl コンポーネント
FormControlコンポーネントは、Chakra UIライブラリの一部であり、フォーム内のコントロールを制御するためのコンテナとして機能します。

- isInvalid
    - FormControlには、フォームのエラー状態を示すためのisInvalidプロパティがあります。
    - !!errors.imageIdは、errors.imageIdが存在する場合にtrueになり、エラーがあることを示します。これにより、エラーの有無に基づいてフォームコントロールのスタイリングが変更されることがあります。

##### Controller コンポーネント
Controllerコンポーネントは、react-hook-formライブラリの一部で、フォーム内の各フィールドの制御を効果的に行うために使用されます。

- rules
    - フィールドに対するバリデーションルールを設定します。
- control
    - useFormフックから提供されるフォームの制御オブジェクトです。
- render
    - Controller内で描画される要素を指定します。この例では隠しフィールドを描画しています。

##### Textarea コンポーネント
Textarea コンポーネントは、ユーザーがメッセージを入力するためのマルチラインのテキスト入力フィールドを提供します。

- register
    - react-hook-formライブラリのregisterメソッドを使用して、Textareaをフォームに登録します。これにより、フォームの状態と連携し、バリデーションやフォームデータの管理が可能になります。
- ...register('message', {...})
    - messageフィールドのバリデーションルールが設定されています。この例では、requiredルールとmaxLengthルールが指定されています。

### 全体のソースコード
最後に、これまでのソースコードをまとめます。

#### ディレクトリ構成
```
.
├── README.md
├── next-env.d.ts
├── node_modules
├── package-lock.json
├── package.json
├── pages
│   ├── _app.tsx
│   ├── constants.ts
│   └── index.tsx
├── public
├── tsconfig.json
└── utils
```

#### 定数の定義
```ts:constants.ts
export const TITLE_FIRST = 'タイトル1';
export const TITLE_SECOND = 'タイトル2';
export const TITLE_THIRD = 'タイトル3';
export const IMAGE_PATH_FIRST = '/images/image_1.png';
export const IMAGE_PATH_SECOND = '/images/image_2.png';
export const IMAGE_PATH_THIRD = '/images/image_3.png';
export const IMAGE_ID_VALIDATION_MESSAGE = '画像を選択してください。';
export const MESSAGE_FIELD_VALIDATION_MESSAGE =
  'メッセージを入力してください。';
export const MESSAGE_FIELD_MAX_LENGTH = 100;
export const MESSAGE_FIELD_MAX_LENGTH_VALIDATION_MESSAGE =
  'メッセージは100文字以内で入力してください。';
export const SUBMIT_BUTTON_TEXT = '送信';
```
#### フォームの作成
```ts:index.tsx
import { CheckIcon } from '@chakra-ui/icons';
import {
  Box,
  Button,
  Flex,
  FormControl,
  FormErrorMessage,
  Heading,
  Image,
  Text,
  Textarea,
  VStack
} from '@chakra-ui/react';
import React, { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import * as FormConstants from './constants';

const IndexPage = () => {
  const images = [
    {
      id: 1,
      title: FormConstants.TITLE_FIRST,
      src: FormConstants.IMAGE_PATH_FIRST,
    },
    {
      id: 2,
      title: FormConstants.TITLE_SECOND,
      src: FormConstants.IMAGE_PATH_SECOND,
    },
    {
      id: 3,
      title: FormConstants.TITLE_THIRD,
      src: FormConstants.IMAGE_PATH_THIRD,
    },
  ];
  const [selectedImageId, setSelectedImageId] = useState(null as number | null);
  console.log("選択した画像ID", selectedImageId);

  const {
    register,
    handleSubmit,
    control,
    setValue,
    formState: { errors },
  } = useForm();

  useEffect(() => {
    setValue('imageId', selectedImageId);
  }, [selectedImageId, setValue]);

  const onSubmit = (data) => {
    const imageId = data.imageId;
    const message = data.message;

    console.log("取得した画像ID", imageId);
    console.log("取得したメッセージ", message);
  };

  return (
    <>
      <Flex alignItems="center" justifyContent="center">
        <VStack spacing="4" align="start" padding="0">
          <VStack align="start">
            <Heading size="lg">画像の選択</Heading>
            <Heading fontSize="14px">画像をクリックすると対象の画像IDを取得できます。</Heading>
          </VStack>
          <FormControl isInvalid={!!errors.imageId}>
            <Controller
              name="imageId"
              control={control}
              defaultValue=""
              rules={{
                required:
                  FormConstants.IMAGE_ID_VALIDATION_MESSAGE,
              }}
              render={({ field }) => (
                <input
                  type="hidden"
                  value={field.value || ''}
                  onChange={field.onChange}
                />
              )}
            />
            {typeof errors.imageId?.message === 'string' && (
              <FormErrorMessage>{errors.imageId.message}</FormErrorMessage>
            )}
          </FormControl>
          {images.map((image, index) => (
            <React.Fragment key={index}>
              <Text>{image.title}</Text>
              <Box
                position="relative"
                key={image.id}
                borderRadius="md"
                onClick={() => {
                  setSelectedImageId(image.id);
                }}
                marginBottom={index === images.length - 1 ? '30px' : '0'}
              >
                <Image
                  src={image.src}
                  alt={`Image ${image.id}`}
                  width="100%"
                  opacity={selectedImageId === image.id ? 1 : 0.6}
                />{' '}
                {selectedImageId === image.id && (
                  <CheckIcon
                    position="absolute"
                    top="5px"
                    right="5px"
                    w={6}
                    h={6}
                    color="green.400"
                  />
                )}{' '}
              </Box>
            </React.Fragment>
          ))}
        </VStack>
      </Flex>
      <Flex alignItems="center" justifyContent="center">
        <form onSubmit={handleSubmit(onSubmit)}>
          <FormControl isInvalid={!!errors.message} width='500px'>
            {typeof errors.message?.message === 'string' && (
              <FormErrorMessage>{errors.message.message}</FormErrorMessage>
            )}
            <VStack align="start" marginBottom="20px">
              <Heading size="lg">メッセージの入力</Heading>
              <Heading fontSize="14px">入力された内容を取得します。</Heading>
            </VStack>
            <Textarea
              {...register('message', {
                required:
                  FormConstants.MESSAGE_FIELD_VALIDATION_MESSAGE,
                maxLength: {
                  value: FormConstants.MESSAGE_FIELD_MAX_LENGTH,
                  message:
                    FormConstants.MESSAGE_FIELD_MAX_LENGTH_VALIDATION_MESSAGE,
                },
              })}
              height="200px"
              border="2px solid black"
              marginBottom="20px"
            />
            <Button
              type="submit"
              width="100%"
              backgroundColor="black"
              color="white"
            >
              {FormConstants.SUBMIT_BUTTON_TEXT}
            </Button>
          </FormControl>
        </form>
      </Flex>
    </>
  );
};

export default IndexPage;
```


## さいごに
今回の記事を通じて、フォームの構築に必要なコンポーネントやライブラリについて深い理解が得られ、新たな知識を獲得できました。
Chakra UIは見やすく、使いやすいUIを簡単に構築するための便利なツールであり、開発プロセスを効率的かつ快適に進めるのに大いに役立ちます。
是非、参考にしていただければ幸いです。