---
title: "Array fields"
description: "Build repeatable sections with stable item identity. Add, remove, reorder, validate, and nest arrays without losing state or errors."
canonical_url: "https://notformdocs.vercel.app/docs/guides/array-fields"
---

> Index of every page on this site: [llms.txt](https://notformdocs.vercel.app/llms.txt)

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.

<code-collapse>

```vue
<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>
```

</code-collapse>

<caution>

Key the loop with `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.

</caution>

## What each part shows

- `arrayField.errors` holds 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's `email` path. Paths passed to `item.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.

<table>
<thead>
  <tr>
    <th>
      Method
    </th>
    
    <th>
      Effect
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        append(value)
      </code>
    </td>
    
    <td>
      Add an item at the end.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        prepend(value)
      </code>
    </td>
    
    <td>
      Add an item at the start.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        insert(index, value)
      </code>
    </td>
    
    <td>
      Insert at an index. Later items shift. The index is clamped to <code>
        0..length
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        remove(index)
      </code>
    </td>
    
    <td>
      Remove the item and its state. Later items shift down.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        move(from, to)
      </code>
    </td>
    
    <td>
      Move an item to another index.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        swap(indexA, indexB)
      </code>
    </td>
    
    <td>
      Exchange two items.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        update(index, value)
      </code>
    </td>
    
    <td>
      Replace an item in place. It keeps its identity and touched state, and its old errors are dropped.
    </td>
  </tr>
</tbody>
</table>

Details:

- Values passed to `append`, `prepend`, `insert`, and `update` are **deep-cloned**, so two items never share an object.
- An out-of-range index for `remove`, `move`, `swap`, or `update` is 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.

<caution>

Do not change the array's structure with `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.

</caution>

## Identity, index, and path

<table>
<thead>
  <tr>
    <th>
      Member
    </th>
    
    <th>
      Stable across moves?
    </th>
    
    <th>
      Use it for
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        item.id
      </code>
    </td>
    
    <td>
      Yes
    </td>
    
    <td>
      The <code>
        v-for
      </code>
      
       key.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        item.index
      </code>
    </td>
    
    <td>
      No, follows position
    </td>
    
    <td>
      Passing to <code>
        remove
      </code>
      
      , <code>
        move
      </code>
      
      , and <code>
        swap
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        item.path
      </code>
    </td>
    
    <td>
      No, follows position
    </td>
    
    <td>
      Input ids and labels, such as <code>
        contacts.2.email
      </code>
      
      .
    </td>
  </tr>
</tbody>
</table>

Fields created from an item, such as `item.field('email')`, follow the item when it moves.

## Reorder with buttons

```vue
<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.

<code-collapse>

```vue
<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>
```

</code-collapse>

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](https://notformdocs.vercel.app/docs/components/array-field) is the component contract.
- [Validation](https://notformdocs.vercel.app/docs/guides/validation) covers configuring an array field's events.

## Sitemap

See the full [sitemap](https://notformdocs.vercel.app/sitemap.md) for all pages.
