react-hook-formを使ってdynamic-inputを実装する

react-hook-formの`useFieldArray`を使ってフォーム内の要素が動的に変化するフォームを作成しようと思います。
またフォームの初期値にはapiから取得したレスポンスを設定します。

<img width="1000" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/551042/02ac52c6-9e3e-1a9e-0dc3-2efc5e76ba7a.png" />

## 環境
`@mui/material 5.15.13`
`next 14.1.3`
`react 18`
`swr 2.2.5`
`zod 3.23.6`

## フォームの定義
作成するフォームでは名前、メールアドレス、電話番号を入力するので`zod`を使って下記のように定義します。

```tsx
const formValueSchema = z.object({
  users: z.array(
    z.object({
      name: z.string().min(1, { message: "名前を入力してください" }),
      email: z.string().min(1, { message: "メールアドレスを入力してください" }),
      phone: z.string().min(1, { message: "電話番号を入力してください" }),
        })
  ),
});
type FormValue = z.infer<typeof formValueSchema>;
```

### ユーザー取得
次に`jsonplaceholder`からユーザー情報を取得します。

```tsx
const Page: NextPageWithLayout = () => {
  const { data, error, isLoading } = useSWR<User[]>(
    "https://jsonplaceholder.typicode.com/users",
    fetcher
  );

  return <></>;
};

Page.getLayout = (page: ReactElement) => {
  return <AuthLayout>{page}</AuthLayout>;
};

export default Page;
```

## フォーム制御
フォームの状態管理のために必要な定数を`useForm`, `useFieldarray`から取得します。
また`useEffect`を使ってフォームの初期値にAPIからのレスポンスを設定しています。

```tsx
const formValueSchema = z.object({
  users: z.array(
    z.object({
      name: z.string().min(1, { message: "名前を入力してください" }),
      email: z.string().min(1, { message: "メールアドレスを入力してください" }),
      phone: z.string().min(1, { message: "電話番号を入力してください" }),
    })
  ),
});
type FormValue = z.infer<typeof formValueSchema>;

const Page: NextPageWithLayout = () => {
  const { data, error, isLoading } = useSWR<User[]>(
    "https://jsonplaceholder.typicode.com/users",
    fetcher
  );

  const defaultValue = {
    name: "",
    email: "",
    phone: "",
  };

  const {
    control,
    handleSubmit,
    reset,
    formState: { isValid },
  } = useForm<FormValue>({
    resolver: zodResolver(formValueSchema),
    defaultValues: {
      users: [defaultValue],
    },
  });

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

  useEffect(() => {
    if (data) {
      reset({
        users: data.map((user) => ({
          name: user.name,
          email: user.email,
          phone: user.phone,
        })),
      });
    }
  }, [reset, data]);

  const onSubmit = (formValue: FormValue) => {
    console.log(formValue);
  };

  return <></>;
};

Page.getLayout = (page: ReactElement) => {
  return <AuthLayout>{page}</AuthLayout>;
};

export default Page;
```

## フォーム表示
初期化したデータを元にフォームを構築します。

```tsx
const formValueSchema = z.object({
  users: z.array(
    z.object({
      name: z.string().min(1, { message: "名前を入力してください" }),
      email: z.string().min(1, { message: "メールアドレスを入力してください" }),
      phone: z.string().min(1, { message: "電話番号を入力してください" }),
    })
  ),
});
type FormValue = z.infer<typeof formValueSchema>;

const Page: NextPageWithLayout = () => {
  const { data, error, isLoading } = useSWR<User[]>(
    "https://jsonplaceholder.typicode.com/users",
    fetcher
  );

  const defaultValue = {
    name: "",
    email: "",
    phone: "",
  };

  const {
    control,
    handleSubmit,
    reset,
    formState: { isValid },
  } = useForm<FormValue>({
    resolver: zodResolver(formValueSchema),
    defaultValues: {
      users: [defaultValue],
    },
  });

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

  useEffect(() => {
    if (data) {
      reset({
        users: data.map((user) => ({
          name: user.name,
          email: user.email,
          phone: user.phone,
        })),
      });
    }
  }, [reset, data]);

  const onSubmit = (formValue: FormValue) => {
    console.log(formValue);
  };

  if (error) {
    return <div>failed to load</div>;
  }

  if (isLoading || !data) {
    return <div>loading...</div>;
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <TableContainer>
        <Table>
          <TableHead>
            <TableRow>
              <TableCell>
                <Typography>Name</Typography>
              </TableCell>
              <TableCell>
                <Typography>Email</Typography>
              </TableCell>
              <TableCell>
                <Typography>Phone</Typography>
              </TableCell>
            </TableRow>
          </TableHead>
          <TableBody>
            {fields.map((field, index) => (
              <TableRow key={field.id}>
                <TableCell>
                  <Controller
                    name={`users.${index}.name`}
                    control={control}
                    render={({ field, fieldState }) => (
                      <TextField
                        {...field}
                        error={fieldState.invalid}
                        helperText={fieldState.error?.message}
                        fullWidth
                      />
                    )}
                  />
                </TableCell>
                <TableCell>
                  <Controller
                    name={`users.${index}.email`}
                    control={control}
                    render={({ field, fieldState }) => (
                      <TextField
                        {...field}
                        error={fieldState.invalid}
                        helperText={fieldState.error?.message}
                        fullWidth
                      />
                    )}
                  />
                </TableCell>
                <TableCell>
                  <Controller
                    name={`users.${index}.phone`}
                    control={control}
                    render={({ field, fieldState }) => (
                      <TextField
                        {...field}
                        error={fieldState.invalid}
                        helperText={fieldState.error?.message}
                        fullWidth
                      />
                    )}
                  />
                </TableCell>
                <TableCell>
                  <Button variant="outlined" onClick={() => remove(index)}>
                    delete
                  </Button>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </TableContainer>
      <Button variant="contained" onClick={() => append(defaultValue)}>
        add
      </Button>
      <Button variant="contained" type="submit">
        save
      </Button>
    </form>
  );
};

Page.getLayout = (page: ReactElement) => {
  return <AuthLayout>{page}</AuthLayout>;
};

export default Page;
```

## 参考
https://dev.classmethod.jp/articles/react-hook-form-use-field-array/#toc-3