Field Arrays
Build dynamic forms with repeatable field groups using the built-in field array API.
Basic Usage
Use the fields() method to manage arrays:
<script setup lang="ts">
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
items: z.array(
z.object({
name: z.string().min(1, 'Name required'),
quantity: z.number().min(1, 'Min 1'),
}),
),
})
const { register, handleSubmit, fields, formState } = useForm({
schema,
defaultValues: {
items: [{ name: '', quantity: 1 }],
},
})
const items = fields('items')
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<div v-for="field in items.value" :key="field.key" class="item-row">
<input v-bind="register(`items.${field.index}.name`)" placeholder="Item name" />
<input v-bind="register(`items.${field.index}.quantity`)" type="number" />
<button type="button" @click="field.remove()">Remove</button>
</div>
<button type="button" @click="items.append({ name: '', quantity: 1 })">Add Item</button>
<button type="submit">Submit</button>
</form>
</template>Critical Rules
Important
Always follow these rules to avoid bugs:
1. Use Dot Notation for Paths
// CORRECT
register(`items.${field.index}.name`)
register('items.0.name')
// WRONG - will fail
register(`items[${field.index}].name`)
register('items[0].name')2. Use field.key for v-for
<!-- CORRECT - stable keys for proper reconciliation -->
<div v-for="field in items.value" :key="field.key"></div>3. Initialize Arrays in defaultValues
// CORRECT
useForm({
schema,
defaultValues: {
items: [], // or with initial items
},
})
// WRONG - may cause undefined errors
useForm({
schema,
// items not initialized
})4. Call fields() in Setup
// CORRECT - call once in setup
const items = fields('items')
// WRONG - don't call in template or computedField Array Methods
append
Add item to end of array:
items.append({ name: '', quantity: 1 })
// Returns false if maxLength exceeded
const success = items.append({ name: '' })
if (!success) {
alert('Maximum items reached')
}prepend
Add item to start of array:
items.prepend({ name: 'First item', quantity: 1 })insert
Insert at specific index:
items.insert(2, { name: 'Inserted item', quantity: 1 })remove
Remove item at index:
items.remove(0) // Remove first item
// Or use the field's remove method
field.remove()
// Returns false if minLength would be violatedremoveAll
Remove all items from array:
items.removeAll()
// Returns false if minLength would be violatedremoveMany
Remove multiple items by indices:
items.removeMany([0, 2, 4]) // Remove items at indices 0, 2, and 4
// Returns false if minLength would be violatedupdate
Update item at index while preserving its stable key:
items.update(0, { name: 'Updated name', quantity: 5 })swap
Swap two items:
items.swap(0, 1) // Swap first and second itemsmove
Move item from one position to another:
items.move(0, 2) // Move first item to third positionreplace
Replace entire array:
items.replace([
{ name: 'New item 1', quantity: 1 },
{ name: 'New item 2', quantity: 2 },
])Validation Mode Behavior
Field array operations (append, prepend, insert, update, remove, swap, move, replace) respect the form's validation mode settings:
| Mode | Validation on Operations |
|---|---|
onSubmit | No validation |
onChange | Validates after each operation |
onBlur | No validation (no blur event) |
onTouched | Validates if array field is touched |
Example with Different Modes
// Mode: onSubmit - operations don't trigger validation
const { fields } = useForm({
schema,
mode: 'onSubmit',
})
const items = fields('items')
items.append({ name: '' }) // No validation
// Mode: onChange - operations trigger validation
const { fields } = useForm({
schema,
mode: 'onChange',
})
const items = fields('items')
items.append({ name: '' }) // Validates the arrayreValidateMode After Submission
After form submission (submitCount > 0), the reValidateMode takes effect:
const { fields, handleSubmit } = useForm({
schema,
mode: 'onSubmit',
reValidateMode: 'onChange',
})
const items = fields('items')
// Before submit: items.append() does NOT validate
await handleSubmit(onSubmit)()
// After submit: items.append() DOES validate (reValidateMode: 'onChange')Consistency
This behavior is consistent with register() and useController(), ensuring uniform validation across your entire form regardless of how fields are managed.
Field Object Properties
Each item in items.value has:
{
key: string, // Stable unique key for v-for
index: number, // Current position in array
remove: () => boolean // Remove this item (returns false if minLength violated)
}Scoped Methods
Each field array item provides scoped methods that automatically build the full path for you. This provides full type safety and cleaner code compared to manually constructing paths.
Why Scoped Methods?
Without scoped methods, you must manually build paths with template literals:
<!-- Manual path building (verbose) -->
<input v-bind="register(`items.${field.index}.name`)" />
<input v-bind="register(`items.${field.index}.price`)" />With scoped methods, the path building is handled automatically:
<!-- Scoped methods (cleaner, type-safe) -->
<input v-bind="field.register('name')" />
<input v-bind="field.register('price')" />Available Scoped Methods
Each field in items.value provides these methods:
| Method | Description |
|---|---|
register(name, options?) | Register a field within the item |
setValue(name, value, options?) | Set a field value within the item |
getValue(name) | Get a field value within the item |
watch(name) | Watch a field value reactively |
getFieldState(name) | Get field state (snapshot, not reactive) |
trigger(name?) | Trigger validation for field(s) |
clearErrors(name?) | Clear errors for field(s) |
setError(name, error) | Set an error for a field |
Usage Example
<script setup lang="ts">
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
items: z.array(
z.object({
name: z.string().min(1, 'Name required'),
price: z.number().min(0, 'Price must be positive'),
}),
),
})
const { fields, formState, handleSubmit } = useForm({
schema,
defaultValues: { items: [{ name: '', price: 0 }] },
})
const items = fields('items')
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<div v-for="field in items.value" :key="field.key" class="item-row">
<!-- Scoped register - automatically builds 'items.0.name', 'items.1.name', etc. -->
<input v-bind="field.register('name')" placeholder="Item name" />
<input v-bind="field.register('price')" type="number" placeholder="Price" />
<!-- Scoped getFieldState for error display -->
<span v-if="field.getFieldState('name').error" class="error">
{{ field.getFieldState('name').error }}
</span>
<button type="button" @click="field.remove()">Remove</button>
</div>
<button type="button" @click="items.append({ name: '', price: 0 })">Add Item</button>
<button type="submit">Submit</button>
</form>
</template>Scoped setValue and getValue
// Get the current price value
const price = field.getValue('price') // number
// Update the price with validation
field.setValue('price', 99.99, { shouldValidate: true })
// Update multiple fields
field.setValue('name', 'New Product')
field.setValue('price', 49.99)Scoped watch
// Watch a single field reactively
const price = field.watch('price') // ComputedRef<number>
// Use in template
<span>Price: {{ field.watch('price').value }}</span>Scoped Validation Methods
// Trigger validation for a specific field
await field.trigger('name')
// Trigger validation for multiple fields
await field.trigger(['name', 'price'])
// Trigger validation for entire item (all fields)
await field.trigger()
// Clear errors for a specific field
field.clearErrors('name')
// Clear all errors for the item
field.clearErrors()
// Set a custom error
field.setError('name', { message: 'Name already exists' })Type Safety
Scoped methods provide full TypeScript autocomplete:
const schema = z.object({
items: z.array(
z.object({
name: z.string(),
price: z.number(),
category: z.object({
id: z.string(),
name: z.string(),
}),
}),
),
})
const items = fields('items')
items.value.forEach((field) => {
// ✅ Autocomplete for 'name', 'price', 'category', 'category.id', 'category.name'
field.register('name')
field.register('category.id')
// ✅ Type-safe values
field.setValue('price', 25) // value must be number
field.setValue('name', 'Product') // value must be string
// ❌ TypeScript error - 'invalid' is not a valid path
field.register('invalid')
})When to Use Scoped Methods
- Use scoped methods when you want cleaner, more readable code with full type safety
- Use manual paths when you need to construct paths dynamically or access fields outside the current item
Both approaches work correctly - choose based on your preference and use case.
Nested Arrays
Handle arrays within arrays:
<script setup>
const schema = z.object({
sections: z.array(
z.object({
title: z.string(),
items: z.array(
z.object({
name: z.string(),
})
),
})
),
})
const { register, fields } = useForm({
schema,
defaultValues: {
sections: [{ title: '', items: [] }],
},
})
const sections = fields('sections')
// For nested arrays, create separate field managers
const getSectionItems = (sectionIndex: number) => {
return fields(`sections.${sectionIndex}.items`)
}
</script>
<template>
<div v-for="section in sections.value" :key="section.key">
<input v-bind="register(`sections.${section.index}.title`)" />
<div v-for="item in getSectionItems(section.index).value" :key="item.key">
<input v-bind="register(`sections.${section.index}.items.${item.index}.name`)" />
<button @click="item.remove()">Remove Item</button>
</div>
<button @click="getSectionItems(section.index).append({ name: '' })">Add Item</button>
</div>
</template>Field Array Rules
Add length constraints and custom validation to field arrays:
Min/Max Length
const items = fields('items', {
rules: {
minLength: { value: 1, message: 'Add at least one item' },
maxLength: { value: 10, message: 'Maximum 10 items allowed' },
},
})
// Operations return false when constraints would be violated
const added = items.append({ name: '' })
if (!added) {
console.log('Cannot add: max length reached')
}
const removed = items.remove(0)
if (!removed) {
console.log('Cannot remove: min length reached')
}Custom Array Validation
const items = fields('items', {
rules: {
validate: (items) => {
const names = items.map((i) => i.name)
const hasDuplicates = new Set(names).size !== names.length
return hasDuplicates ? 'Duplicate names are not allowed' : true
},
},
})Async validation is also supported:
const items = fields('items', {
rules: {
validate: async (items) => {
const isValid = await validateItemsOnServer(items)
return isValid ? true : 'Invalid item combination'
},
},
})Focus Options
Control focus behavior when adding items. Focus is opt-in by default (shouldFocus: false):
// Enable focus on the new item after appending
items.append({ name: '' }, { shouldFocus: true })
// Default behavior: no automatic focus
items.append({ name: '' }) // shouldFocus defaults to false
// When adding multiple items, focus a specific one
items.append([{ name: '' }, { name: '' }], { focusIndex: 1 })
// Focus a specific field within the item
items.append(
{ name: '', email: '' },
{ focusName: 'email' }, // Focuses the 'email' field of the new item
)Focus options work with append, prepend, and insert:
// Insert at index 2 and focus the 'quantity' field
items.insert(2, { name: '', quantity: 1 }, { focusName: 'quantity' })Schema Validation
Array-level and item-level validation work together:
const schema = z.object({
items: z
.array(
z.object({
name: z.string().min(1, 'Name required'),
email: z.email('Invalid email'),
}),
)
.min(1, 'Add at least one item')
.max(5, 'Maximum 5 items'),
})Access errors:
<template>
<!-- Array-level error -->
<p v-if="formState.value.errors.items && typeof formState.value.errors.items === 'string'">
{{ formState.value.errors.items }}
</p>
<div v-for="field in items.value" :key="field.key">
<!-- Item-level errors -->
<input v-bind="register(`items.${field.index}.name`)" />
<span v-if="formState.value.errors.items?.[field.index]?.name">
{{ formState.value.errors.items[field.index].name }}
</span>
</div>
</template>Complete Example
<script setup lang="ts">
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
orderItems: z
.array(
z.object({
product: z.string().min(1, 'Select a product'),
quantity: z.number().min(1, 'Min 1').max(99, 'Max 99'),
notes: z.string().optional(),
}),
)
.min(1, 'Add at least one item')
.max(10, 'Maximum 10 items'),
})
const products = [
{ id: 'widget', name: 'Widget', price: 9.99 },
{ id: 'gadget', name: 'Gadget', price: 19.99 },
{ id: 'gizmo', name: 'Gizmo', price: 29.99 },
]
const { register, handleSubmit, fields, formState, watch } = useForm({
schema,
defaultValues: {
orderItems: [{ product: '', quantity: 1, notes: '' }],
},
})
const orderItems = fields('orderItems')
const watchedItems = watch('orderItems')
const total = computed(() => {
return (
watchedItems.value?.reduce((sum, item) => {
const product = products.find((p) => p.id === item.product)
return sum + (product?.price || 0) * (item.quantity || 0)
}, 0) || 0
)
})
const onSubmit = (data: z.infer<typeof schema>) => {
console.log('Order:', data)
}
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<h2>Order Items</h2>
<p
v-if="
formState.value.errors.orderItems && typeof formState.value.errors.orderItems === 'string'
"
class="error"
>
{{ formState.value.errors.orderItems }}
</p>
<div v-for="field in orderItems.value" :key="field.key" class="order-row">
<div class="field">
<select v-bind="register(`orderItems.${field.index}.product`)">
<option value="">Select product</option>
<option v-for="p in products" :key="p.id" :value="p.id">
{{ p.name }} - ${{ p.price }}
</option>
</select>
<span v-if="formState.value.errors.orderItems?.[field.index]?.product" class="error">
{{ formState.value.errors.orderItems[field.index].product }}
</span>
</div>
<div class="field">
<input
v-bind="register(`orderItems.${field.index}.quantity`)"
type="number"
min="1"
max="99"
style="width: 80px"
/>
<span v-if="formState.value.errors.orderItems?.[field.index]?.quantity" class="error">
{{ formState.value.errors.orderItems[field.index].quantity }}
</span>
</div>
<div class="field">
<input
v-bind="register(`orderItems.${field.index}.notes`)"
placeholder="Notes (optional)"
/>
</div>
<button type="button" @click="field.remove()" :disabled="orderItems.value.length <= 1">
Remove
</button>
</div>
<div class="actions">
<button
type="button"
@click="orderItems.append({ product: '', quantity: 1, notes: '' })"
:disabled="orderItems.value.length >= 10"
>
Add Item
</button>
</div>
<div class="total">
<strong>Total: ${{ total.toFixed(2) }}</strong>
</div>
<button type="submit" :disabled="formState.value.isSubmitting">Place Order</button>
</form>
</template>Next Steps
- Learn about Conditional Fields
- Explore Form Context for complex forms
