Dynamic field arrays

Forms that edit a list — tags on a post, line items on an invoice, social links on a profile — get seven typed helpers on any array path.

The helpers

import { z } from 'zod'
import { useForm } from 'attaform/zod'

const schema = z.object({
  tags: z.array(z.string()),
  posts: z.array(
    z.object({
      title: z.string(),
      views: z.number(),
    })
  ),
})

const form = useForm({ schema, key: 'blog-editor' })

form.append('tags', 'new-tag') // push
form.prepend('tags', 'first-tag') // unshift
form.insert('tags', 2, 'at-index-two') // splice-insert
form.remove('tags', 0) // splice-remove
form.swap('tags', 0, 2) // exchange two indices
form.move('tags', 3, 1) // move from → to, shift others
form.replace('tags', 0, 'replaced-in-place') // in-place; never grows

Every helper is type-narrowed:

  • Path is ArrayPath<Form>append('title', …) on a string field is a compile error.
  • Value is ArrayItem<Form, Path>append('posts', 'not-a-post') is a compile error.

Out-of-range behaviour:

  • remove / swap / move / replace no-op on invalid indices.
  • insert clamps via Array.prototype.splice semantics (splice(-1, 0, v) inserts just before the last item).

The v-for pattern

<script setup lang="ts">
  import { useForm } from 'attaform/zod'
  import { z } from 'zod'

  const schema = z.object({
    posts: z.array(
      z.object({
        title: z.string(),
        views: z.number(),
      })
    ),
  })

  const form = useForm({ schema, key: 'blog-editor' })
  const posts = computed(() => form.values.posts ?? [])
</script>

<template>
  <div v-for="(post, index) in posts" :key="index">
    <input v-register="form.register(`posts.${index}.title`)" />
    <input v-register="form.register(`posts.${index}.views`)" type="number" />
    <button type="button" @click="form.remove('posts', index)">Remove</button>
  </div>
  <button type="button" @click="form.append('posts', { title: '', views: 0 })"> Add post </button>
</template>

Template literals like `posts.${index}.title` type-narrow correctly — you get the same type safety as a static path.

Keying rows when items don't have IDs

:key="index" is fine for a display-only list but breaks when rows reorder or get removed (Vue reuses nodes across what are conceptually different rows). Two patterns that work:

1. Client-generated stable ID on append. Add an id to the row and key by it:

form.append('posts', {
  id: crypto.randomUUID(),
  title: '',
  views: 0,
})
<div v-for="post in posts" :key="post.id">…</div>

2. External counter. nextId ticks per append:

const nextId = ref(0)
function addPost() {
  form.append('posts', { title: '', views: 0 })
  nextId.value++
}

Weaker (remounts reset the counter) but good enough in-session.

For lists that only ever append and never reorder, raw index keys are OK.

values vs fields

  • form.values.posts[0]?.titlestring | undefined. Reads carry | undefined once a path crosses an array index — at runtime, posts[0] could be missing (sparse, deleted, fresh-mount empty). Narrow with ?. / optional checks before non-null operations. Tuple positions stay strict (their length is static).
  • form.fields.posts[0].title → reactive per-field state at the path. Leaf props: value, dirty, errors, touched, focused, blurred, blank, isConnected, path. Same | undefined taint on value once an array index is crossed.
<template>
  <div v-for="(post, index) in posts" :key="post.id">
    <input v-register="form.register(`posts.${index}.title`)" />
    <span v-if="form.fields.posts[index].title.errors.length > 0" class="error">
      {{ form.fields.posts[index].title.errors[0].message }}
    </span>
  </div>
</template>

The directive's v-register binding handles undefined correctly (renders empty), so most templates don't need defensive narrowing. Defensive narrowing matters when scripts read the value:

const upper = form.values.posts[0]?.title?.toUpperCase() ?? ''

For ref-shaped interop (e.g. watch(emailRef, …) / external composables), use form.toRef('posts.0.title') to get a Readonly<Ref<string | undefined>> at the same path.

Stale state on removal

When you remove a row, errors + field records at the removed path stay in the store until explicitly cleared — the helper can't know which indices map to which errors after a shift. If the stale state matters to you:

form.remove('posts', 2)
form.clearFieldErrors() // or form.resetField('posts') for full clean slate

For large rearrangements, form.resetField('posts') rebuilds the subtree cleanly.

Building arrays in bulk

Building an array by looping append is O(N²) — each call copies the whole array. For a large seed, assign in one shot:

form.setValue('items', nextArray) // O(N), one assignment

setValue types lead with the full element shape — pass elements matching the schema's element type. If you have partial elements from a server payload (some keys missing), the runtime mergeStructural fills missing keys from the schema's element default, so the bulk assignment still produces a structurally complete array. The type system points the IDE at the canonical "give me the whole shape" pattern at the call site; the runtime backstop catches dynamic / server-shaped inputs.

Sparse-index writes auto-pad

setValue('posts.21', { ... }) against an empty posts array fills indices 0..20 with the schema's element default before writing index 21. The structural-completeness invariant means the array is never sparse on disk — every index < length is a fully-shaped element matching the schema:

const form = useForm({
  schema: z.object({ posts: z.array(z.object({ title: z.string() })) }),
  key: 'blog',
})

// posts is initially []
form.setValue('posts.5.title', 'sixth post')
// posts.length === 6
// posts[0..4] are { title: '' } (the schema's element default)
// posts[5] is { title: 'sixth post' }

This makes posts.length honest and lets v-for over posts render N rows without filtering for undefined. Most consumers never write sparse indices intentionally — the invariant just means the framework no longer has to guess what to do when one slips through.