Rio Blog

世界のどこかでゆるく生きるITエンジニアのブログ

React Hook FormのuseFieldArrayで可変フォームを実装する

入力フィールドの数や項目を動的に変化させたい場合、React Hook FormというライブラリのuseFieldArrayというフックを実装することで実現可能です。なお、UIライブラリとしてはMUI v.5を利用します。

フィールドの追加・削除

まずは、シンプルにフィールドの追加と削除ができるフォームを作って、ついで登録ボタンで入力データを確認しましょう。

<App.tsx>

import { useFieldArray, useForm } from 'react-hook-form'
import {
  Button, Container, Stack, TextField
} from '@mui/material'

export default function App() {

  // フィールドの初期値となる配列を設定 
  const { register, handleSubmit, control } = useForm({
    defaultValues: { register: [{ name: "" }] }
  })

  // 一意のnameを設定
  // append, removeでそれぞれ追加, 削除が可能
  const { fields, append, remove } = useFieldArray({
    control,
    name: "register",
  })

  const submit = (data: any) => {
    console.log(data)
  }

  return (
    <Container maxWidth="sm" sx={{ pt: 5 }}>
      <form onSubmit={handleSubmit(submit)}>
        <Stack spacing={3}>

          // fieldsをmapメソッドで展開
          {fields.map((field, index) => (
            <Stack direction={"row"} spacing={3}>
              <TextField
                label="Name"
                key={field.id}
                {...register(`register.${index}.name`)}
                fullWidth
              />

              // 削除ボタン
              // 引数: 削除する要素のインデックス
              <Button
                type="button"
                color="primary"
                variant="contained"
                onClick={() => remove(index)}
                disabled={index === 0 ? true : false}
              >
                Delete
              </Button>
            </Stack>
          ))}

          // 追加ボタン
          // 引数: 初期値を設定した追加要素のオブジェクト
          <Button
            type="button"
            color="primary"
            variant="contained"
            onClick={() => append({ name: "" })}
          >
            Add
          </Button>

          <Button
            type="submit"
            color="secondary"
            variant="contained"
          >
            Register
          </Button>
        </Stack>
      </form>
    </Container>
  )
}

ブラウザで動作を確認

初期表示

追加ボタンと削除ボタンで、それぞれテキストフィールドの追加したり削除できます。

登録ボタンをクリックして、送信されたデータを確認すると、ちゃんと配列形式で取得できていますね。

実用的なフォームにアップデート

  • 有/無のラジオボタンで、有を選択するとフォームを表示。
  • フォームの数は、追加・削除ボタンで操作可能。
  • 各フォームの合計金額を表示。
  • バリデーションを実装。
  • UIはMUIを使用。

上記の仕様を満たすようなより実用的なフォームにアップデートしてみましょう。

<App.tsx>

import { useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import {
  Button, Container, FormControl, FormControlLabel, Paper, Radio, RadioGroup, Stack, TextField
} from '@mui/material'
import IconButton from '@mui/material/IconButton'
import DeleteIcon from '@mui/icons-material/Delete';
import { TotalPrice } from './Calculate';

export type formType = {
  isExists: string,
  items: {
    item: string
    quantity: number
    price: number
  }[]
}

export default function App() {

  const [isExists, setIsExists] = useState(false)

  const { register, handleSubmit, reset, control, formState: { errors } } = useForm<formType>({
    defaultValues: { items: [{ item: "", quantity: 0, price: 0 }] }
  })

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

  // ラジオボタン操作時の処理
  const changeIsExists = (value: string) => {
    if (value === "false") {
      setIsExists(false)
      reset()
    } else {
      setIsExists(true)
    }
  }

  const submit = (data: formType) => {
    console.log(data)
  }

  return (
    <Container maxWidth="sm" sx={{ pt: 5 }}>
      <Paper sx={{ p: 5 }}>
        <form onSubmit={handleSubmit(submit)}>
          <Stack spacing={3}>
            <FormControl>
              <RadioGroup row value={isExists} onChange={(event) => changeIsExists(event.target.value)}>
                <FormControlLabel value="false" control={<Radio {...register("isExists")} />} label="No" />
                <FormControlLabel value="true" control={<Radio {...register("isExists")} />} label="Yes" />
              </RadioGroup>
            </FormControl>
            {isExists &&
              <>
                {
                  fields.map((field, index) => (
                    <Stack direction={"row"} spacing={3} key={field.id}>

                      <TextField
                        label="Item"
                        {...register(`items.${index}.item`, { required: "error" })}
                        fullWidth
                        error={Boolean(errors.items?.[index]?.item)}
                      />

                      <TextField
                        type="number"
                        label="Quantity"
                        {...register(`items.${index}.quantity`, { min: 1 })}
                        fullWidth
                        error={Boolean(errors.items?.[index]?.quantity)}
                        helperText={errors.items?.[index]?.quantity?.message}
                      />

                      <TextField
                        type="number"
                        label="Price"
                        {...register(`items.${index}.price`, { min: 1 })}
                        fullWidth
                        error={Boolean(errors.items?.[index]?.price)}
                        helperText={errors.items?.[index]?.price?.message}
                      />

                      <IconButton
                        onClick={() => remove(index)}
                        disabled={fields.length < 2 ? true : false}
                      >
                        <DeleteIcon
                        />
                      </IconButton>

                    </Stack>
                  ))
                }
                <TotalPrice control={control} />

                < Button
                  type="button"
                  color="primary"
                  variant="contained"
                  onClick={() => append({ item: "", quantity: 0, price: 0 })}
                >
                  Add
                </Button>
              </>
            }

            <Button
              type="submit"
              color="secondary"
              variant="contained"
            >
              Register
            </Button>
          </Stack>
        </form>
      </Paper>
    </Container >
  )
}

同階層に合計金額を計算して返すコンポーネントを置きます。

<Calculate.tsx>

import { useWatch, Control } from "react-hook-form"
import { formType } from "./App"
import { Box, Typography } from '@mui/material'

type Props = {
  control: Control<formType>
}

export const TotalPrice = ({ control }: Props) => {

  // useWatchで対象の値を監視
  const formValues = useWatch({
    control,
    name: "items",
  })

  // 監視している値から合計を計算
  const total = formValues.reduce(
    (total, { quantity, price }) => total + (quantity * price), 0
  )

  return (
    <Box textAlign="right">
      <Typography variant="h6">Total Price: {total}-</Typography>
    </Box>
  )
}

ブラウザで確認

こんな感じ。

以上、動的に変化するフォームの実装でした。