Array fields
Use form.arrayField(path) for an array of values that users add, remove, or reorder. ArrayField exposes the array and its items through a slot.
Render and bind items
Each item is a field for its own value, with an id, an index, and field() and arrayField() for nested values.
<script lang="ts" setup>
import { ArrayField, Field, FieldError, useForm } from 'notform'
import { z } from 'zod'
const itemSchema = z.object({
email: z.email('Enter a valid email address.')
})
const schema = z.object({
contacts: z.array(itemSchema)
.min(1, 'Add at least one contact.'),
})
const form = useForm({
initialValues: { contacts: [{ email: '' }] },
schema,
})
</script>
<template>
<ArrayField
v-slot="{ arrayField }"
:array-field="form.arrayField('contacts')"
>
<div
v-for="item in arrayField.items"
:key="item.id"
>
<Field
v-slot="{ field }"
:field="item.field('email')"
>
<label :for="field.path">Contact email</label>
<input
:id="field.path"
v-model="field.model"
v-bind="field.events"
type="email"
>
<FieldError :errors="field" />
</Field>
<button
type="button"
@click="arrayField.remove(item.index)"
>
Remove
</button>
</div>
<FieldError :errors="arrayField" />
<button
type="button"
@click="arrayField.append({ email: '' })"
>
Add contact
</button>
</ArrayField>
</template>
item.id, never with the index. After an insertion or reorder, the same item has a different index, and an index key makes Vue reuse the wrong DOM and component state.What each part shows
arrayField.errorsholds issues at the array's own path, such as the minimum count.<FieldError :errors="arrayField" />displays the first.item.field('email')exposes the issues at that item'semailpath. Paths passed toitem.field()are relative to the item and typed from the item's shape.- For an array of primitives, pass the item itself to
Field:<Field v-slot="{ field }" :field="item">.
Mutate through the array field
Use these methods for every structural change. They keep item identity, touched state, and errors aligned with the values.
| Method | Effect |
|---|---|
append(value) | Add an item at the end. |
prepend(value) | Add an item at the start. |
insert(index, value) | Insert at an index. Later items shift. The index is clamped to 0..length. |
remove(index) | Remove the item and its state. Later items shift down. |
move(from, to) | Move an item to another index. |
swap(indexA, indexB) | Exchange two items. |
update(index, value) | Replace an item in place. It keeps its identity and touched state, and its old errors are dropped. |
Details:
- Values passed to
append,prepend,insert, andupdateare deep-cloned, so two items never share an object. - An out-of-range index for
remove,move,swap, orupdateis ignored. - If the array does not exist yet, the first insertion creates it.
- Each mutation counts as a change event on the array field. By default this re-validates the array's own path, so a "minimum items" error appears or clears immediately. It does not validate the items' fields.
form.values.contacts.push(), splice(), sort(), or by replacing the array. Direct changes bypass the methods that keep item identity and error paths aligned. Item ids are reconciled by length only, so errors and touched state can end up on the wrong items. Writing to a single item's field through its model is fine.Identity, index, and path
| Member | Stable across moves? | Use it for |
|---|---|---|
item.id | Yes | The v-for key. |
item.index | No, follows position | Passing to remove, move, and swap. |
item.path | No, follows position | Input ids and labels, such as contacts.2.email. |
Fields created from an item, such as item.field('email'), follow the item when it moves.
Reorder with buttons
<template>
<div
v-for="item in arrayField.items"
:key="item.id"
>
<!-- fields -->
<button
type="button"
:disabled="item.index === 0"
@click="arrayField.move(item.index, item.index - 1)"
>
Up
</button>
<button
type="button"
:disabled="item.index === arrayField.items.length - 1"
@click="arrayField.move(item.index, item.index + 1)"
>
Down
</button>
</div>
</template>
Nested arrays
Each item can create nested fields and nested arrays. Paths are relative to the item and follow it as it moves.
<script lang="ts" setup>
import { ArrayField, Field, FieldError, useForm } from 'notform'
import { z } from 'zod'
const membersSchema = z.array(z.object({
email: z.email('Enter a valid email address.')
}))
.min(1, 'Add at least one member.')
const itemSchema = z.object({
members: membersSchema,
name: z.string().min(1, 'Name the group.'),
})
const schema = z.object({
groups: z.array(itemSchema).min(1),
})
const form = useForm({
initialValues: {
groups: [{ members: [{ email: '' }], name: '' },],
},
schema,
})
</script>
<template>
<ArrayField
v-slot="{ arrayField: groups }"
:array-field="form.arrayField('groups')"
>
<fieldset
v-for="group in groups.items"
:key="group.id"
>
<Field
v-slot="{ field }"
:field="group.field('name')"
>
<input
v-bind="field.events"
:id="field.path"
v-model="field.model"
aria-label="Group name"
:name="field.path"
>
<FieldError :errors="field" />
</Field>
<ArrayField
v-slot="{ arrayField: members }"
:array-field="group.arrayField('members')"
>
<div
v-for="member in members.items"
:key="member.id"
>
<Field
v-slot="{ field }"
:field="member.field('email')"
>
<input
v-bind="field.events"
:id="field.path"
v-model="field.model"
type="email"
:name="field.path"
>
<FieldError :errors="field" />
</Field>
<button
type="button"
@click="members.remove(member.index)"
>
Remove member
</button>
</div>
<FieldError :errors="members" />
<button
type="button"
@click="members.append({ email: '' })"
>
Add member
</button>
</ArrayField>
<button
type="button"
@click="groups.remove(group.index)"
>
Remove group
</button>
</fieldset>
<button
type="button"
@click="groups.append({ name: '', members: [{ email: '' }] })"
>
Add group
</button>
</ArrayField>
</template>
When an inner array is removed along with its group, its fields are disposed. A stale reference to a removed item is inert: mutations do nothing and its items are empty.
Errors follow items
When items are inserted, removed, moved, or swapped, existing errors are re-pointed to the item's new index. Removing an item drops its errors. update drops the replaced item's old errors. The schema's own array-level rules, such as .min(1), stay on the array path.
Reset
form.reset() restores the baseline array and gives every item a fresh id. The keyed list re-renders, so item components are rebuilt. This is expected: reset begins a new editing state from the baseline.
Next steps
- ArrayField is the component contract.
- Validation covers configuring an array field's events.