React Hook Formで動的な入力フォームを作ってみた

2024.05.01

入力フォームをユーザー操作などにより追加したり削除したり並び順を変えたりする動的な入力フォームを作ってみました。

useFieldArrayとは

Reactアプリケーションで入力フォームを作成し管理するためのライブラリとして人気なReact Hook Formの機能の一つです。

useFieldArrayを利用することで動的な入力フォームを簡単に作ることができます。

開発環境

Next.js + TypeScript + MUI + React Hook Formを利用します。

Next.jsのプロジェクト作成

npx create-next-app@latest

MUIのインストール

npm install @mui/material @emotion/react @emotion/styled

React Hook Formのインストール

npm install react-hook-form

useFieldArrayの基本的な使い方

まずはフォームの追加、削除、リセットをできるようにします。

  • useForm
    • React Hook Formの基本的な設定を行い、フォームデータ取得やフォームのリセットに利用する関数を呼び出しています。
  • useFieldArray
    • 動的にフォームを作成するための設定と関数の呼び出しをしています。
  • fields
    • フォームに関する状態の情報が含まれています。
  • append
    • fieldsの最後にフォームを1つ追加することができます、その際に値も設定することができます。
  • remove
    • フォームのインデックスを指定してフォームを1つ削除します。

src/app/page.tsx

"use client";

import styles from "./page.module.css";
import { Box, Button, Stack, TextField } from "@mui/material";
import { Controller, useFieldArray, useForm } from "react-hook-form";

export default function Home() {
  // フォームの型定義
  type FormValues = {
    profile: {
      firstName: string;
      lastName: string;
    }[];
  };

  // デフォルトの値
  const defaultValue = { firstName: "", lastName: "" };

  // React Hook Form の設定
  const { control, handleSubmit, reset } = useForm<FormValues>({
    mode: "onTouched",
    defaultValues: {
      profile: [defaultValue],
    },
  });

  // useFieldArrayの設定と関数の呼び出し
  const { fields, append, remove } = useFieldArray({
    control: control,
    name: "profile",
  });

  // フォームの送信処理
  const onSubmit = async (data: FormValues) => {
    console.log(data.profile);
  };

  return (
    <main className={styles.main}>
      <Box component="form" noValidate onSubmit={handleSubmit(onSubmit)}>
        {/* 動的に追加される入力フォーム */}
        {fields.map((field, index) => {
          return (
            <Stack key={field.id} direction={"row"} spacing={1} my={1}>
              {/* 名前の入力フォーム */}
              <Controller
                name={`profile.${index}.firstName`}
                control={control}
                render={({ field }) => (
                  <TextField {...field} placeholder={"名前"} />
                )}
              />

              {/* 名字の入力フォーム */}
              <Controller
                name={`profile.${index}.lastName`}
                control={control}
                render={({ field }) => (
                  <TextField {...field} placeholder={"名字"} />
                )}
              />

              {/* フォームの削除ボタン */}
              <Button variant="outlined" onClick={() => remove(index)}>
                削除
              </Button>
            </Stack>
          );
        })}

        <Stack spacing={1}>
          {/* フォーム追加ボタン */}
          <Button
            variant="contained"
            fullWidth
            onClick={() => append(defaultValue)}
          >
            追加
          </Button>

          {/* フォーム送信ボタン */}
          <Button
            variant="contained"
            fullWidth
            onClick={handleSubmit(onSubmit)}
          >
            送信
          </Button>

          {/* フォームリセットボタン */}
          <Button variant="outlined" fullWidth onClick={() => reset()}>
            リセット
          </Button>
        </Stack>
      </Box>
    </main>
  );
}

順番を指定してフォームを追加

これまでは末尾にフォームを追加していましたが、ここでは追加ボタンのひとつ下に追加できるようにします。

  • insertを利用することで特定の位置にフォームを挿入することができます。
    • insert(index: number, value: any)
    • index:挿入する位置のindex
    • value:挿入する要素の値

src/app/page.tsx

"use client";

import styles from "./page.module.css";
import { Box, Button, Stack, TextField } from "@mui/material";
import { Controller, useFieldArray, useForm } from "react-hook-form";

export default function Home() {
  // フォームの型定義
  type FormValues = {
    profile: {
      firstName: string;
      lastName: string;
    }[];
  };

  // デフォルトの値
  const defaultValue = { firstName: "", lastName: "" };

  // React Hook Form の設定
  const { control, handleSubmit, reset } = useForm<FormValues>({
    mode: "onTouched",
    defaultValues: {
      profile: [defaultValue],
    },
  });

  // useFieldArrayの設定と関数の呼び出し
  const { fields, insert, remove } = useFieldArray({
    control: control,
    name: "profile",
  });

  // フォームの送信処理
  const onSubmit = async (data: FormValues) => {
    console.log(data.profile);
  };

  return (
    <main className={styles.main}>
      <Box component="form" noValidate onSubmit={handleSubmit(onSubmit)}>
        {/* 動的に追加される入力フォーム */}
        {fields.map((field, index) => {
          return (
            <Stack key={field.id} direction={"row"} spacing={1} my={1}>
              {/* 名前の入力フォーム */}
              <Controller
                name={`profile.${index}.firstName`}
                control={control}
                render={({ field }) => (
                  <TextField {...field} placeholder={"名前"} />
                )}
              />

              {/* 名字の入力フォーム */}
              <Controller
                name={`profile.${index}.lastName`}
                control={control}
                render={({ field }) => (
                  <TextField {...field} placeholder={"名字"} />
                )}
              />

              {/* フォームの位置指定して追加ボタン */}
              <Button
                variant="contained"
                onClick={() => insert(index + 1, defaultValue)}
              >
                追加
              </Button>

              {/* フォームの削除ボタン */}
              <Button variant="outlined" onClick={() => remove(index)}>
                削除
              </Button>
            </Stack>
          );
        })}

        <Stack spacing={1}>
          {/* フォーム送信ボタン */}
          <Button
            variant="contained"
            fullWidth
            onClick={handleSubmit(onSubmit)}
          >
            送信
          </Button>

          {/* フォームリセットボタン */}
          <Button variant="outlined" fullWidth onClick={() => reset()}>
            リセット
          </Button>
        </Stack>
      </Box>
    </main>
  );
}

フォームの並び替え

フォームを「一つ上」と「一つ下」に移動できるようにします。

  • moveを利用することでフォームを指定した位置に移動することができます。
    • move(from: number, to: number)
    • from:移動させたいフォームの現在の位置を示すindex
    • to:移動後のフォームの位置を示すindex

src/app/page.tsx

"use client";

import styles from "./page.module.css";
import { Box, Button, Stack, TextField } from "@mui/material";
import { Controller, useFieldArray, useForm } from "react-hook-form";

export default function Home() {
  // フォームの型定義
  type FormValues = {
    profile: {
      firstName: string;
      lastName: string;
    }[];
  };

  // デフォルトの値
  const defaultValue = { firstName: "", lastName: "" };

  // React Hook Form の設定
  const { control, handleSubmit, reset } = useForm<FormValues>({
    mode: "onTouched",
    defaultValues: {
      profile: [defaultValue],
    },
  });

  // useFieldArrayの設定と関数の呼び出し
  const { fields, insert, remove, move } = useFieldArray({
    control: control,
    name: "profile",
  });

  // フォームの送信処理
  const onSubmit = async (data: FormValues) => {
    console.log(data.profile);
  };

  return (
    <main className={styles.main}>
      <Box component="form" noValidate onSubmit={handleSubmit(onSubmit)}>
        {/* 動的に追加される入力フォーム */}
        {fields.map((field, index) => {
          return (
            <Stack key={field.id} direction={"row"} spacing={1} my={1}>
              {/* 名前の入力フォーム */}
              <Controller
                name={`profile.${index}.firstName`}
                control={control}
                render={({ field }) => (
                  <TextField {...field} placeholder={"名前"} />
                )}
              />

              {/* 名字の入力フォーム */}
              <Controller
                name={`profile.${index}.lastName`}
                control={control}
                render={({ field }) => (
                  <TextField {...field} placeholder={"名字"} />
                )}
              />

              {/* フォームの位置指定して追加ボタン */}
              <Button
                variant="contained"
                onClick={() => insert(index + 1, defaultValue)}
              >
                追加
              </Button>

              {/* フォームの削除ボタン */}
              <Button variant="outlined" onClick={() => remove(index)}>
                削除
              </Button>

              {/* 一つ下に移動 */}
              <Button
                variant="contained"
                onClick={() => move(index, index + 1)}
              >
                ↓
              </Button>

              {/* 一つ上に移動 */}
              <Button
                variant="contained"
                onClick={() => move(index, index - 1)}
              >
                ↑
              </Button>
            </Stack>
          );
        })}

        <Stack spacing={1}>
          {/* フォーム送信ボタン */}
          <Button
            variant="contained"
            fullWidth
            onClick={handleSubmit(onSubmit)}
          >
            送信
          </Button>

          {/* フォームリセットボタン */}
          <Button variant="outlined" fullWidth onClick={() => reset()}>
            リセット
          </Button>
        </Stack>
      </Box>
    </main>
  );
}

バリデーション

入力必須と最大文字数のバリデーションを実装します。

  • validationRulesにバリデーションルールを定義
  • fieldState.invalidでバリデーションエラーの有無をチェック
  • fieldState.error?.messageでバリデーションエラーメッセージを表示

src/app/page.tsx

"use client";

import styles from "./page.module.css";
import { Box, Button, Stack, TextField } from "@mui/material";
import { Controller, useFieldArray, useForm } from "react-hook-form";

export default function Home() {
  // フォームの型定義
  type FormValues = {
    profile: {
      firstName: string;
      lastName: string;
    }[];
  };

  // デフォルトの値
  const defaultValue = { firstName: "", lastName: "" };

  // React Hook Form の設定
  const { control, handleSubmit, reset } = useForm<FormValues>({
    mode: "onTouched",
    defaultValues: {
      profile: [defaultValue],
    },
  });

  // useFieldArrayの設定と関数の呼び出し
  const { fields, insert, remove, move } = useFieldArray({
    control: control,
    name: "profile",
  });

  // フォームの送信処理
  const onSubmit = async (data: FormValues) => {
    console.log(data.profile);
  };

  // バリデーションルール
  const validationRules = {
    lastName: {
      required: "名字を入力してください",
      maxLength: {
        value: 20,
        message: "最大20文字",
      },
    },
    firstName: {
      required: "名前を入力してください",
      maxLength: {
        value: 20,
        message: "最大20文字",
      },
    },
  };

  return (
    <main className={styles.main}>
      <Box component="form" noValidate onSubmit={handleSubmit(onSubmit)}>
        {/* 動的に追加される入力フォーム */}
        {fields.map((field, index) => {
          return (
            <Stack key={field.id} direction={"row"} spacing={1} my={1}>
              {/* 名前の入力フォーム */}
              <Controller
                name={`profile.${index}.firstName`}
                control={control}
                rules={validationRules.firstName}
                render={({ field, fieldState }) => (
                  <TextField
                    {...field}
                    placeholder={"名前"}
                    // バリデーションエラーの場合はフォームをエラー状態にする
                    error={fieldState.invalid}
                    // エラーメッセージを表示
                    helperText={fieldState.error?.message}
                  />
                )}
              />

              {/* 名字の入力フォーム */}
              <Controller
                name={`profile.${index}.lastName`}
                control={control}
                rules={validationRules.lastName}
                render={({ field, fieldState }) => (
                  <TextField
                    {...field}
                    placeholder={"名字"}
                    // バリデーションエラーの場合はフォームをエラー状態にする
                    error={fieldState.invalid}
                    // エラーメッセージを表示
                    helperText={fieldState.error?.message}
                  />
                )}
              />

              {/* フォームの位置指定して追加ボタン */}
              <Button
                variant="contained"
                onClick={() => insert(index + 1, defaultValue)}
              >
                追加
              </Button>

              {/* フォームの削除ボタン */}
              <Button variant="outlined" onClick={() => remove(index)}>
                削除
              </Button>

              {/* 一つ下に移動 */}
              <Button
                variant="contained"
                onClick={() => move(index, index + 1)}
              >
                ↓
              </Button>

              {/* 一つ上に移動 */}
              <Button
                variant="contained"
                onClick={() => move(index, index - 1)}
              >
                ↑
              </Button>
            </Stack>
          );
        })}

        <Stack spacing={1}>
          {/* フォーム送信ボタン */}
          <Button
            variant="contained"
            fullWidth
            onClick={handleSubmit(onSubmit)}
          >
            送信
          </Button>

          {/* フォームリセットボタン */}
          <Button variant="outlined" fullWidth onClick={() => reset()}>
            リセット
          </Button>
        </Stack>
      </Box>
    </main>
  );
}

整える

ユーザーが利用しやすいように下記の様な細かな調整を行います。

  • 最後の一つのフォームは削除できないように
  • 最大件数の制限を設ける
  • 件数を表示
  • 一番上または下のフォームはそれ以上は移動できないように
  • バリデーションエラーがある場合は送信できないように

src/app/page.tsx

"use client";

import styles from "./page.module.css";
import { Box, Button, Stack, TextField } from "@mui/material";
import { Controller, useFieldArray, useForm } from "react-hook-form";

export default function Home() {
  // フォームの型定義
  type FormValues = {
    profile: {
      firstName: string;
      lastName: string;
    }[];
  };

  // デフォルトの値
  const defaultValue = { firstName: "", lastName: "" };

  // React Hook Form の設定
  const {
    control,
    handleSubmit,
    reset,
    formState: { isValid },
  } = useForm<FormValues>({
    mode: "onTouched",
    defaultValues: {
      profile: [defaultValue],
    },
  });

  // useFieldArrayの設定と関数の呼び出し
  const { fields, insert, remove, move } = useFieldArray({
    control: control,
    name: "profile",
  });

  // フォームの送信処理
  const onSubmit = async (data: FormValues) => {
    console.log(data.profile);
  };

  // バリデーションルール
  const validationRules = {
    lastName: {
      required: "名字を入力してください",
      maxLength: {
        value: 20,
        message: "最大20文字",
      },
    },
    firstName: {
      required: "名前を入力してください",
      maxLength: {
        value: 20,
        message: "最大20文字",
      },
    },
  };

  // 最大件数
  const MAXIMUM_USER = 5;

  return (
    <main className={styles.main}>
      <Box component="form" noValidate onSubmit={handleSubmit(onSubmit)}>
        {/* 動的に追加される入力フォーム */}
        {fields.map((field, index) => {
          return (
            <Stack key={field.id} direction={"row"} spacing={1} my={1}>
              {/* 名前の入力フォーム */}
              <Controller
                name={`profile.${index}.firstName`}
                control={control}
                rules={validationRules.firstName}
                render={({ field, fieldState }) => (
                  <TextField
                    {...field}
                    placeholder={"名前"}
                    // バリデーションエラーの場合はフォームをエラー状態にする
                    error={fieldState.invalid}
                    // エラーメッセージを表示
                    helperText={fieldState.error?.message}
                  />
                )}
              />

              {/* 名字の入力フォーム */}
              <Controller
                name={`profile.${index}.lastName`}
                control={control}
                rules={validationRules.lastName}
                render={({ field, fieldState }) => (
                  <TextField
                    {...field}
                    placeholder={"名字"}
                    // バリデーションエラーの場合はフォームをエラー状態にする
                    error={fieldState.invalid}
                    // エラーメッセージを表示
                    helperText={fieldState.error?.message}
                  />
                )}
              />

              {/* フォームの位置指定して追加ボタン */}
              <Button
                variant="contained"
                onClick={() => insert(index + 1, defaultValue)}
                disabled={fields.length >= MAXIMUM_USER}
              >
                追加
              </Button>

              {/* フォームの削除ボタン */}
              <Button
                variant="outlined"
                onClick={() => remove(index)}
                disabled={fields.length === 1}
              >
                削除
              </Button>

              {/* 一つ下に移動 */}
              <Button
                variant="contained"
                onClick={() => move(index, index + 1)}
                disabled={fields.length <= index + 1}
              >
                ↓
              </Button>

              {/* 一つ上に移動 */}
              <Button
                variant="contained"
                onClick={() => move(index, index - 1)}
                disabled={!index}
              >
                ↑
              </Button>
            </Stack>
          );
        })}

        <Stack spacing={1}>
          {/* フォーム送信ボタン */}
          <Button
            variant="contained"
            fullWidth
            onClick={handleSubmit(onSubmit)}
            disabled={!isValid}
          >
            送信({fields.length}件)
          </Button>

          {/* フォームリセットボタン */}
          <Button variant="outlined" fullWidth onClick={() => reset()}>
            リセット
          </Button>
        </Stack>
      </Box>
    </main>
  );
}

おわりに

この様に動的な入力フォームを作成する際はReact Hook FormのuseFieldArrayを利用することで簡単に実装することができるのでおすすめです。