🐈

react-hook-form×MUIで親ページと子コンポーネントで複雑なバリデーションを行う

2023/09/12に公開

使うライブラリなど

・react.js
・next.js
・react-hook-form
・Material Ui

やりたいこと

react-hook-formを使って、親ページでも子コンポーネントでもバリデーションをかけたい。
イメージとしては以下。

子コンポーネントは「氏名」、「年齢」、「職業」を入力するインプットフォームになっており、そのインプットフォーム自体を追加したり削除したりできる。
以下は子コンポーネントのインプットフォームを1つ追加した時の様子です。

ただし、submit時には、インプットフォームが1個以上ないといけないように設定します。

また、インプットフォームに記入がない時は以下のように赤線で教えてくれます。

問題なくsubmitできると以下のようにオブジェクトが作られています。

コード

親ページ

index.tsx
import { Box, Button, Container, TextField } from "@mui/material";
import AddPeople from "../component/people/AddPeople";
import {
  FormProvider,
  useForm,
  Controller,
  useFieldArray,
} from "react-hook-form";

export interface PurchaseFormInput {
  product_name: string;
  price: number;
  quantity: number;
  receivers: Receiver[];
}

export type Receiver = {
  receiver_name: string;
  age: number;
  occupation: string;
};

const Form = () => {
  const methods = useForm<PurchaseFormInput>({
    defaultValues: {
      receivers: [
        {
          receiver_name: "",
          age: 20,
          occupation: "",
        },
      ],
    },
  });
  const { handleSubmit, control } = methods;

  const { fields, append, remove } = useFieldArray({
    control,
    name: "receivers",
  });

  const removeIndex = (index: number) => {
    remove(index);
  };

  const submit = (data: any) => {
    if (data.receivers.length == 0) {
      console.log("データがありません");
      return;
    }
    console.log(data); // フォームの内容が入る
  };

  return (
    <Container maxWidth="xs" sx={{ mt: 6 }}>
      <FormProvider {...methods}>
        <Box component="form" onSubmit={handleSubmit(submit)}>
          <Controller
            name="product_name"
            control={control}
            rules={{
              required: { value: true, message: "入力が必須の項目です" },
              validate: (value) => {
                if (value !== null || "") {
                  return true;
                }
                return "文字を入力してください";
              },
            }}
            render={({ field, fieldState, formState: { errors } }) => (
              <TextField
                {...field}
                type="product_name"
                size="small"
                sx={{ width: 500 }}
                placeholder="例:えんぴつ"
                error={fieldState.invalid}
                helperText={
                  fieldState.invalid ? errors.product_name?.message : ""
                }
              />
            )}
          />
          <Box>
            {fields.map((field, index) => {
              return (
                <AddPeople
                  key={field.id}
                  index={index}
                  removeIndex={removeIndex}
                />
              );
            })}
          </Box>
          <button
            type="button"
            onClick={() => {
              append({
                receiver_name: "",
                age: 20,
                occupation: "",
              });
            }}
          >
            追加
          </button>
          <Box textAlign="right">
            <Button variant="contained" onClick={handleSubmit(submit)}>
              送信
            </Button>
          </Box>
        </Box>
      </FormProvider>
    </Container>
  );
};

export default Form;


子コンポーネント

AddPeople.tsx
import { TextField, Stack } from "@mui/material";
import { Controller, useFormContext } from "react-hook-form";
import { PurchaseFormInput } from "../../pages/index";
interface Receiver {
  index: number;
  removeIndex: (index: number) => void;
}
const AddPeople = (props: Receiver) => {
  const { index, removeIndex } = props;
  const { control } = useFormContext<PurchaseFormInput>();

  return (
    <>
      <Controller
        name={`receivers.${index}.receiver_name`}
        control={control}
        rules={{
          required: { value: true, message: "必須入力" },
          validate: (value) => {
            if (value !== null || "") {
              return true;
            }
            return "文字を入力してください";
          },
        }}
        render={({ field, formState: { errors } }) => (
          <Stack spacing={2}>
            <TextField
              {...field}
              label="receiver_name"
              fullWidth
              placeholder="田中太郎"
              error={errors.receivers?.[index]?.receiver_name ? true : false}
              helperText={errors.receivers?.[index]?.message as string}
            />
          </Stack>
        )}
      />
      <Controller
        name={`receivers.${index}.age`}
        control={control}
        rules={{
          required: { value: true, message: "必須入力" },
          validate: (value) => {
            if (!Number.isNaN(Number(value))) {
              return true;
            }
            return "数字を入力してください(全角不可)";
          },
        }}
        render={({ field, formState: { errors } }) => (
          <Stack spacing={2}>
            <TextField
              {...field}
              label="age"
              fullWidth
              placeholder="20"
              error={errors.receivers?.[index]?.receiver_name ? true : false}
              helperText={errors.receivers?.[index]?.message as string}
            />
          </Stack>
        )}
      />
      <Controller
        name={`receivers.${index}.occupation`}
        control={control}
        rules={{
          required: { value: true, message: "必須入力" },
          validate: (value) => {
            if (value !== null || "") {
              return true;
            }
            return "職業を入力してください";
          },
        }}
        render={({ field, formState: { errors } }) => (
          <Stack spacing={2}>
            <TextField
              {...field}
              label="occupation"
              fullWidth
              placeholder="学生"
              error={errors.receivers?.[index]?.receiver_name ? true : false}
              helperText={errors.receivers?.[index]?.message as string}
            />
          </Stack>
        )}
      />
      <button
        type={"button"}
        onClick={() => removeIndex(index)}
        style={{ marginLeft: "16px" }}
      >
        削除
      </button>
    </>
  );
};

export default AddPeople;

注意ポイント1

react-hook-formでは型があっているか判断するために、useFormにインプットの型があっているか比較するinterfaceやtypeなどを渡してあげないといけません。
今回だとPurchaseFormImputというinterfaceをuseFormに渡しています。

index.tsx
export interface PurchaseFormInput {
  product_name: string;
  price: number;
  quantity: number;
  receivers: Receiver[];
}

export type Receiver = {
  receiver_name: string;
  age: number;
  occupation: string;
};

~~~省略~~~
const methods = useForm<PurchaseFormInput>({
    defaultValues: {
      receivers: [
        {
          receiver_name: "",
          age: 20,
          occupation: "",
        },
      ],
    },
  });

今回は、親ページと子コンポーネントであるAddPeopleの2つでreact-hook-formのバリデーションをかけたいので、PurchaseFormInputの中に子コンポーネントでチェックしてほしいtypeをreceivers: Receiver[];として渡しています。

注意ポイント2

子コンポーネントのAddpeopleでは、インプットフィールドの箇所のnameプロパティは、以下のようにindexごとの要素を渡します。

AddPeople.tsx
 <Controller
        name={`receivers.${index}.receiver_name`}
        control={control}

コードを記載しているgithub

https://github.com/Jo-Shino/react-practice

Discussion