Dynamic and Nested Forms

Repeatable field groups that add and remove rows without inputs losing focus, because the key is a stable id.

Syntax<div v-for="row in rows" :key="row.id">

Some forms have a variable number of answers: invite several people, add line items, list previous addresses. The state for that is an array of objects, one object per row, and the template renders it with v-for.

The key decides what survives

v-for needs :key, and for editable rows the key has to be a stable id, not the loop index. Vue uses keys to decide which existing DOM to reuse. If the key is the index, deleting the second of five rows renumbers everything after it, so Vue reuses each element for a different row: focus jumps and inputs appear to keep the wrong value. Generate an id when you push the row and key on that.

Adding and removing

Mutate the array in place with push and splice; both are tracked. Find the row by id rather than trusting an index captured in the template. Remember that a reactive array cannot be reassigned, because reactivity belongs to the proxy you replaced. If a row list ever needs replacing wholesale, hold it in a ref and assign rows.value.

Nested values

Nested objects need no special handling: v-model="form.address.city" works, and reactivity is deep by default. Group related fields in a <fieldset> with a <legend> so the structure is not only visual. Ids have to stay unique across rows, so build them from the row id.

Do not put v-if and v-for on the same element. v-if is evaluated first and cannot see the loop variable. Filter in a computed instead.

Example

Example · vue
<!-- src/components/InviteRows.vue -->
<script setup lang="ts">
import { computed, reactive } from 'vue'

type Invite = {
  id: number
  email: string
  role: 'viewer' | 'editor'
}

let nextId = 1
const rows = reactive<Invite[]>([{ id: nextId++, email: '', role: 'viewer' }])

const filled = computed(() => rows.filter((row) => row.email !== '').length)

function addRow() {
  rows.push({ id: nextId++, email: '', role: 'viewer' })
}

function removeRow(id: number) {
  const index = rows.findIndex((row) => row.id === id)
  if (index !== -1) rows.splice(index, 1)
}
</script>

<template>
  <fieldset>
    <legend>Invite people</legend>
    <div v-for="(row, index) in rows" :key="row.id" class="row">
      <label :for="`email-${row.id}`">Email {{ index + 1 }}</label>
      <input :id="`email-${row.id}`" v-model="row.email" type="email" />
      <select v-model="row.role" :aria-label="`Role for row ${index + 1}`">
        <option value="viewer">Viewer</option>
        <option value="editor">Editor</option>
      </select>
      <button type="button" :disabled="rows.length === 1" @click="removeRow(row.id)">
        Remove
      </button>
    </div>
    <button type="button" @click="addRow">Add another</button>
    <p>{{ filled }} of {{ rows.length }} rows filled in</p>
  </fieldset>
</template>

<style scoped>
.row {
  display: flex;
  gap: 0.5rem;
  align-items: center;
}
</style>

When to use it

  • Let a user invite any number of teammates from one form.
  • Edit invoice line items where rows are added and deleted in any order.
  • Collect a nested address object inside a larger profile form.
  • Keep the row an input belongs to correct after the row above it is deleted.

More examples

A ref when the whole list gets replaced

A reset button replaces the entire array. Assigning to a `reactive` array would throw away the proxy the template is watching and the list would stop updating, so the state lives in a `ref` and `items.value` is reassigned. Note `v-model.number` on the quantity input, which keeps the value a number rather than a numeric string.

Example · vue
<!-- src/components/LineItems.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'

type Item = { id: number; label: string; quantity: number }

let nextId = 1
function blankItem(): Item {
  return { id: nextId++, label: '', quantity: 1 }
}

const items = ref<Item[]>([blankItem()])
const total = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0))

function reset() {
  items.value = [blankItem()]
}
</script>

<template>
  <div>
    <div v-for="item in items" :key="item.id">
      <input v-model="item.label" :aria-label="`Item ${item.id}`" />
      <input v-model.number="item.quantity" type="number" min="1" />
    </div>
    <button type="button" @click="items.push(blankItem())">Add item</button>
    <button type="button" @click="reset">Start over</button>
    <p>{{ total }} units</p>
  </div>
</template>

<style scoped>
input[type='number'] {
  width: 5rem;
}
</style>

A row as its own component

The parent renders `<invite-row v-for="(row, index) in rows" :key="row.id" v-model="rows[index]" @remove="removeRow" />`. The row component never mutates a prop: the value arrives through `defineModel`, and removal is a request the parent decides to honour, because only the parent owns the array.

Example · vue
<!-- src/components/InviteRow.vue -->
<script setup lang="ts">
export type Invite = {
  id: number
  email: string
  role: 'viewer' | 'editor'
}

const row = defineModel<Invite>({ required: true })
const emit = defineEmits<{ remove: [id: number] }>()
</script>

<template>
  <div class="row">
    <input v-model="row.email" type="email" :aria-label="`Email for invite ${row.id}`" />
    <select v-model="row.role">
      <option value="viewer">Viewer</option>
      <option value="editor">Editor</option>
    </select>
    <button type="button" @click="emit('remove', row.id)">Remove</button>
  </div>
</template>

<style scoped>
.row {
  display: flex;
  gap: 0.5rem;
}
</style>

Repeatable fields with VeeValidate

`useFieldArray` gives each entry a `key` that is not its index, plus `push`, `remove`, `insert`, `swap`, `move`, `update` and `replace`. The key is the whole point: it stays with a row as the array shifts, which is the same rule as generating your own id, handled for you. Error paths are indexed, so the message for a row reads `invites[0].email`.

Example · vue
<!-- src/components/InviteFieldArray.vue -->
<script setup lang="ts">
import { useForm, useFieldArray } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'

const { errors, handleSubmit } = useForm({
  validationSchema: toTypedSchema(
    z.object({
      invites: z.array(z.object({ email: z.email('Enter a valid email address') }))
    })
  ),
  initialValues: { invites: [{ email: '' }] }
})

const { fields, push, remove } = useFieldArray<{ email: string }>('invites')

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

<template>
  <form @submit="onSubmit">
    <div v-for="(field, index) in fields" :key="field.key">
      <input v-model="field.value.email" type="email" :aria-label="`Invite ${index + 1}`" />
      <p v-if="errors[`invites[${index}].email`]">{{ errors[`invites[${index}].email`] }}</p>
      <button type="button" @click="remove(index)">Remove</button>
    </div>
    <button type="button" @click="push({ email: '' })">Add invite</button>
    <button type="submit">Send invites</button>
  </form>
</template>

<style scoped>
p {
  color: #b00020;
}
</style>

Discussion

  • Be the first to comment on this lesson.