skip to content

In a VeeValidate 4 sign-up form, how do you render a repeatable phone list with useFieldArray, and why key rows by field.key but name them by index?

level: seniorimportance: should knowfreq 19%

answer

  1. needs a form above it
  2. read-only entries with a key
  3. helpers instead of mutation
  4. paths are index based
  5. names must stay reactive

basics

~20 s

useFieldArray('phones') returns read-only fields plus helpers such as push and remove. Key the v-for on field.key, a stable generated id, but name each input phones[idx], because the form stores an array and paths are positional.

solid answer

~40 s

`useFieldArray('phones')` needs a form context from `useForm` in the same or an ancestor component; without one it warns in development and returns helpers that do nothing. It returns `fields`, read-only entries with `value`, `key`, `isFirst` and `isLast`, and helpers such as `push`, `remove`, `insert`, `swap`, `move`, `update` and `replace` that you use instead of mutating values. Iterate with `:key="field.key"`: the key is generated per entry and survives reordering, so Vue keeps each row's component. Name each field by position, `phones[${idx}]`, because that is its path in the form's array and in `errors`. After `remove(0)` the second row becomes `phones[0]`, so a row component must take its name reactively, as in `useField(() => props.name)`.

code

vue · 16 lines
vue
<script setup lang="ts">
import { useForm, useFieldArray } from 'vee-validate'
import PhoneInput from './PhoneInput.vue'

useForm({ initialValues: { phones: [''] } })

const { fields, push, remove } = useFieldArray<string>('phones')
</script>

<template>
  <div v-for="(field, idx) in fields" :key="field.key">
    <PhoneInput :name="`phones[${idx}]`" />
    <button type="button" @click="remove(idx)">Remove</button>
  </div>
  <button type="button" @click="push('')">Add phone</button>
</template>

go deeper

for a junior

Recall that useFieldArray returns fields plus push and remove, and that each row is named phones[idx].

for a middle

Explain the difference between field.key and the index, why values must not be mutated, and how array errors are keyed.

for a senior

Show the removal bug with a frozen useField name, the dev warning without a form context, and where a list-level rule's message lands.

for a principal

Decide whether rows are bound inline or through shared row components across the app, weighing per-row meta against simplicity.

## What useFieldArray manages A sign-up form that accepts several phone numbers stores them as an array at one path, `phones`. `useFieldArray(path)` gives you a view of that array and the operations to change it: | Member | Purpose | |---|---| | `fields` | read-only entries: `value`, `key`, `isFirst`, `isLast` | | `push(v)`, `prepend(v)`, `insert(i, v)` | add rows | | `remove(i)` | delete a row and its path state | | `swap(a, b)`, `move(from, to)` | reorder rows | | `update(i, v)`, `replace(arr)` | replace one row's value or the whole array | It requires a form: `useForm()` must run in the same component before it, or in an ancestor. Without one it logs a development warning and returns no-op helpers, which is a confusing failure if you miss the warning. ## Two identifiers per row, for two jobs - **`field.key`** is generated when the entry is created, from a counter, and stays with that entry when rows are removed or moved. It is what `v-for` needs, so Vue reuses the right component instance for each row. The docs warn it is **not** an index and must not be used in field names. - **The index** in `phones[${idx}]` is the field's path in the form values. The array has no gaps, so after `remove(0)` the old second row is now `phones[0]`. Errors follow the same flat keys: `errors['phones[1]']`. Using the index as the `v-for` key makes Vue reuse the wrong component after a removal; using `field.key` as the path writes values to keys that do not exist in the array. ## Binding each row There are two common ways: 1. **Directly on the entry.** `<input v-model="field.value">` works: the entry's `value` is writable and calls `update()` under the hood. Validation then runs in the form's normal mode, so a row's message appears once that path has been validated, at the latest on submit. 2. **Through a row component.** A `PhoneInput` component calling `useField` registers each row as a real field with its own `errorMessage`, `meta` and blur handling. Pass the name as a getter: `useField(() => props.name)`. If you pass `props.name` as a plain string, the component keeps its original path after a removal and edits the wrong row. This is the docs' first composition-API caveat. ## Initial state and removal - Seed the array: `initialValues: { phones: [''] }` renders one empty row and gives the schema a value to check. - The helpers call a silent validation after each change, so `meta.valid` stays accurate without showing messages on rows the user has not reached. - When a field unmounts, its path is destroyed unless `keepValuesOnUnmount` (form) or `keepValueOnUnmount` (field) is set; field arrays remove the row's path explicitly on `remove`. ## Validating the list With Zod, `phones: z.array(z.string().regex(/^[0-9+ ]{7,15}$/, 'Invalid number'))` validates each row, and the adapter reports issues at `phones[0]`, `phones[1]`, matching the field names. A minimum count, such as `.min(1, 'Add a phone')`, reports at `phones` itself, so render that message once above the list. ## The component-API equivalent Template-first code can use the renderless `<FieldArray>` component, which calls `useFieldArray` internally and exposes the same members through its default slot: - `<FieldArray name="phones" v-slot="{ fields, push, remove }">` wraps the rows; in the 4.15 source the prop is `name`, while the API table on the docs page calls it `arrayPath`, so the source wins; - each row renders a `<Field>` whose `name` is bound to the row's indexed path, such as `phones[0]`, and `<Field>` already tracks its name reactively, so the removal bug of a frozen `useField` name does not arise; - it needs a surrounding `<Form>` or a `useForm` call in a parent, exactly like the composable. ## Mistakes worth catching - `:key="idx"` on the rows: works until the first removal, then row-local state sticks to the wrong number. - `values.phones.push('')`: `values` is read-only; use `push('')`. - A row component calling `useField(props.name)`: the path is frozen at creation. - Calling `useFieldArray` in a child with no form above it: a dev warning and silently inert buttons.

  • How do you reorder phone numbers so the primary one comes first?
    Call `move(idx, 0)` or `swap(idx, 0)` rather than rebuilding the array. The entries keep their generated keys, so Vue moves the existing row components, and the paths are recomputed from the new positions.
  • Where does an error such as 'add at least one phone' appear?
    A rule on the array itself, like `z.array(...).min(1)`, is reported at the path `phones`, not at an index. Render `errors.phones` once above or below the list; the per-row messages live at `phones[0]`, `phones[1]` and so on.

saying these in an interview costs you the question

  • field.key is the row's index and can be used in the field name.
  • Keying the v-for by index is fine because VeeValidate tracks rows itself.
  • You add a row by pushing onto values.phones.
  • useField(props.name) inside a row component stays correct after removals.
  • Array errors are nested, read as errors.phones[1].