【Vue.js】動的フォームの作り方とバリデーション設計|項目の追加・削除を柔軟に対応

【Vue.js】動的フォームの作り方とバリデーション設計|項目の追加・削除を柔軟に対応 Vue.js

複数の連絡先、参加者、明細行のように、ユーザーが入力項目を自分で増やしたり減らしたりするフォームを、動的フォームと呼びます。項目の数が決まっていないため、配列でデータを持ち、v-forで描画するのが基本です。この記事では、Vue 3(<script setup>)で、追加・削除、項目ごとのエラー表示、送信までを動くコードで整理します。

スポンサーリンク

配列と v-for で項目を追加・削除する

入力欄を、配列の要素に1対1で対応させます。pushで追加し、filterで削除します。ここで大事なのがkeyです。各項目に固有のidを持たせ、:key="field.id"と指定します。インデックスをkeyにすると、途中の項目を削除したときに、要素とデータの対応がずれる原因になります。理由はv-forで配列やオブジェクトをループして表示する方法で解説しています。入力欄はv-modelで、配列の要素に結びつけます。

Vue:項目の追加と削除
<script setup>
import { ref } from 'vue'

const MAX = 5          // 追加できる項目の上限
let nextId = 2         // 次に付ける id

// 各項目に、固有の id を持たせる
const fields = ref([{ id: 1, name: '' }])

function addField() {
  if (fields.value.length >= MAX) return
  fields.value.push({ id: nextId++, name: '' })
}

function removeField(id) {
  if (fields.value.length <= 1) return   // 最後の1件は消さない
  fields.value = fields.value.filter(field => field.id !== id)
}
</script>

<template>
  <div v-for="field in fields" :key="field.id">
    <input v-model="field.name" placeholder="名前を入力">
    <button
      type="button"
      :disabled="fields.length <= 1"
      @click="removeField(field.id)"
    >
      削除
    </button>
  </div>

  <button type="button" :disabled="fields.length >= MAX" @click="addField">
    項目を追加
  </button>
</template>

上のコードでは、運用でよく困る2点にも備えています。項目数の上限(MAX)を決め、最後の1件は削除できないようにしています。

項目ごとのバリデーションと送信

動的フォームのバリデーションで大切なのは、「どの項目が間違っているか」を、その項目の近くに出すことです。computedで、項目のidごとにエラーメッセージを作り、入力欄の下に表示します。送信ボタンを押す前から赤く表示すると、入力中に煩わしいので、submittedのフラグで、送信後だけ見せています。

Vue:項目ごとのエラー表示と送信
<script setup>
import { ref, computed } from 'vue'

const MAX = 5
let nextId = 2
const fields = ref([{ id: 1, name: '' }])
const submitted = ref(false)   // 送信ボタンを押したあとだけ、エラーを見せる

function addField() {
  if (fields.value.length >= MAX) return
  fields.value.push({ id: nextId++, name: '' })
}
function removeField(id) {
  if (fields.value.length <= 1) return
  fields.value = fields.value.filter(field => field.id !== id)
}

// 項目ごとのエラーメッセージ({ id: メッセージ })
const errors = computed(() =>
  Object.fromEntries(
    fields.value.map(field => [
      field.id,
      field.name.trim() === '' ? '名前を入力してください' : ''
    ])
  )
)
const hasError = computed(() => Object.values(errors.value).some(msg => msg !== ''))

function onSubmit() {
  submitted.value = true
  if (hasError.value) return

  // 送信するデータの形を整える(前後の空白を除く)
  const payload = fields.value.map(field => field.name.trim())
  console.log(payload)
}
</script>

<template>
  <form @submit.prevent="onSubmit">
    <div v-for="field in fields" :key="field.id">
      <input v-model="field.name" placeholder="名前を入力">
      <button type="button" @click="removeField(field.id)">削除</button>
      <p v-if="submitted && errors[field.id]" class="error">
        {{ errors[field.id] }}
      </p>
    </div>

    <button type="button" @click="addField">項目を追加</button>
    <button type="submit">送信</button>
  </form>
</template>

送信するデータは、画面の状態そのままではなく、送信用に整えます。上の例では、前後の空白を取り除いた名前の配列にしています。computedの使い方はcomputedプロパティで動的なデータ処理を行う方法、フォーム全般のバリデーションは入力フォームのバリデーション機能を実装する方法をご覧ください。

VeeValidateで動的フォームを作る

項目の種類やルールが増えてきたら、バリデーションライブラリのVeeValidateが便利です。配列のフォームには、useFieldArrayを使います。まずパッケージを入れます。VeeValidateの導入の詳細はバリデーションライブラリvee-validateの導入と使い方をご覧ください。

Shell:パッケージのインストール
npm install vee-validate yup @vee-validate/yup

Yupのスキーマは、@vee-validate/yupのtoTypedSchemaで包んで渡します。公式は、この書き方を推奨しています。生のYupスキーマも渡せますが、フォームの値に型が付かず、必須の項目でも、送信された値がnull許容のままになるためです。

Vue:useFieldArrayによる動的フォーム
<script setup>
import { useForm, useFieldArray, Field, ErrorMessage } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/yup'
import * as yup from 'yup'

// スキーマ:people という配列の各要素に、name が必須というルール
const schema = toTypedSchema(
  yup.object({
    people: yup
      .array()
      .of(
        yup.object({
          name: yup.string().required('名前は必須です')
        })
      )
      .min(1)
  })
)

const { handleSubmit } = useForm({
  validationSchema: schema,
  initialValues: { people: [{ name: '' }] }
})

// fields は { key, value, isFirst, isLast } の配列。push / remove で増減できる
const { fields, push, remove } = useFieldArray('people')

const onSubmit = handleSubmit(values => {
  console.log(values.people)
})
</script>

<template>
  <form @submit="onSubmit">
    <!-- key には、自動で付く field.key を使う -->
    <div v-for="(field, idx) in fields" :key="field.key">
      <Field :name="`people[${idx}].name`" placeholder="名前を入力" />
      <button type="button" @click="remove(idx)">削除</button>
      <ErrorMessage :name="`people[${idx}].name`" />
    </div>

    <button type="button" @click="push({ name: '' })">項目を追加</button>
    <button type="submit">送信</button>
  </form>
</template>
  • useFieldArray('people')が返すfieldsは、key、value、isFirst、isLastを持つ要素の配列です。
  • v-forのkeyには、自動で付くfield.keyを使います。
  • 各入力欄は、people[0].nameのような名前で、フォームの値に結びつけます。
  • push、removeのほか、insert、swap、moveなどで、並べ替えもできます。

実用シーンと拡張

  • 明細付きの請求フォーム(項目、金額、数量)。金額と数量から合計をcomputedで計算する
  • 複数のファイル名やURLの登録欄
  • 質問と回答のセットを増やす入力画面
  • 項目の中に、さらに配列を持つ入れ子の構造(useFieldArrayを入れ子で使う)

関連記事

よくある質問(FAQ)

Qkeyにインデックスを使うと、どんな問題が起きますか?
A途中の項目を削除したり、先頭に追加したりすると、要素とデータの対応がずれ、入力途中の状態が別の行に残ることがあります。項目ごとに固有のidを持たせて、keyにしてください。VeeValidateではfield.keyです。
Qエラーはいつ表示するのがよいですか?
A送信ボタンを押したあと、または項目から離れたあとが自然です。入力を始める前から、全項目が赤くなっていると、使いにくくなります。
QVeeValidateでYupを使うとき、toTypedSchemaは必須ですか?
A生のYupスキーマも渡せますが、公式は@vee-validate/yupのtoTypedSchemaで包む書き方を推奨しています。型が付き、必須項目の値がnull許容にならないためです。
Q項目数の上限や、最後の1件を残す制御は必要ですか?
A用途によりますが、入れておくと安全です。上限がないと、不要な項目が無制限に増え、最後の1件も消せてしまうと、入力欄が何もない状態になります。

まとめ

動的フォームは、固有のidを持つ配列と、v-forで作ります。バリデーションは、どの項目が間違っているかを項目ごとに示し、送信時には送信用の形に整えます。ルールが増えたら、VeeValidateのuseFieldArrayとtoTypedSchemaに任せると、見通しよく管理できます。