web-forms-vee-validate · git:20260906.5c10830 · 2026-09-06 · sha256 cb18d30f8dab4ccf

web-forms-vee-validate git:20260906.5c10830B

Immutable. This exact content is served forever at /api/v1/blob/cb18d30f8dab4ccf.

---
name: web-forms-vee-validate
description: VeeValidate v4 patterns - useForm, useField, defineField, useFieldArray, schema validation with Composition API
---

# VeeValidate Form Validation Patterns

> **Quick Guide:** `useForm` owns the form state; `defineField` returns a `[model, attrs]` tuple to
> `v-model` onto a native input, and `useField` binds a field inside a reusable input component —
> with its name passed as a getter so it stays reactive. A schema reaches `validationSchema` through
> `toTypedSchema()`, which is also what supplies the types. `useFieldArray` drives repeatable groups
> and iterates on `field.key`. Everything here is v4; v5 removes the adapter, so check
> [reference.md](reference.md) before targeting it.

**Detailed Resources:**

- [examples/core.md](examples/core.md) — `defineField`, inline rules, a reusable input built on `useField`, form meta, eager validation
- [examples/validation.md](examples/validation.md) — schema through `toTypedSchema`, conditional schemas, all errors per field
- [examples/arrays.md](examples/arrays.md) — `useFieldArray`, nested arrays, reordering
- [reference.md](reference.md) — return-value tables, composition helpers, worked anti-patterns, v5 migration

---

## Which path applies

- **The form is built in the component around native inputs** — `useForm` plus `defineField`, and
  `v-model` on the returned model. Follow [examples/core.md](examples/core.md).
- **A reusable input component** — `useField` inside the component, taking its name as
  `() => props.name`. Follow [examples/core.md](examples/core.md) Pattern 3.
- **The renderless `<Form>` and `<Field>` components** — an alternative to the Composition API
  rather than a companion to it. Each creates its own form context, so a component that calls
  `useForm` and also renders `<Form>` has two, and the fields register with whichever they are
  nested in.

---

<critical_requirements>

## Before writing VeeValidate code

**Wrap a schema in `toTypedSchema()` before handing it to `validationSchema`.** The adapter converts
the schema's result into the shape the form reads and is what supplies the value types; a raw schema
is accepted without complaint and then validates nothing.

**Pass `useField` its name as `() => props.name` or `toRef(props, "name")`.** A plain `props.name` is
read once at setup, so the field keeps binding to the name the component first mounted with.

**Give `initialValues` an entry for every field, arrays included.** Field arrays iterate their value
and fail on `undefined`, and a cross-field rule is skipped entirely when one of the keys it compares
is missing.

**Iterate `useFieldArray` on `field.key`.** It is the identity VeeValidate assigns each entry and it
survives insertion and reordering, which a positional index does not.

</critical_requirements>

---

**Auto-detection:** vee-validate, @vee-validate/zod, @vee-validate/yup, @vee-validate/valibot,
useForm, useField, defineField, useFieldArray, toTypedSchema, validationSchema, errorBag,
handleSubmit, resetForm, setFieldError, setErrors, validateOnValueUpdate, keepValuesOnUnmount,
ErrorMessage, useFormContext

**Applies to:**

- Vue form state, validation timing and submission
- Reusable input components that bind themselves to a named field
- Repeatable field groups that add, remove and reorder
- Surfacing server-side validation failures against individual fields
- Multi-step forms where fields unmount between steps

**Handled elsewhere:**

- Authoring the validation schema — `toTypedSchema` adapts whatever schema it is given, and how that
  schema states its rules is settled by whatever owns it.
- Choosing a schema library — an adapter package exists for each, and the choice sits outside this
  skill.
- Markup and styling of the inputs — every example uses plain elements, so the classes are yours.
- Where the initial values came from — `initialValues` is a plain object the form does not fetch.

---

<philosophy>

Validation is declared once, at the form, and read per field. `useForm` holds the schema and the
values; each field asks the form for its own slice, so a field knows its error without knowing the
rule that produced it. That is what lets an input component be written without knowing which form it
will be dropped into — it takes a name and binds itself.

Reactivity is the thing to keep intact. Everything a field receives from the form is a ref or a
getter, and the common failure is flattening one of them: reading `props.name` instead of passing a
getter, or unwrapping a computed schema with `.value` before the form can track it.

</philosophy>

---

<decision_framework>

## defineField or useField

|              | `defineField`                               | `useField`                                    |
| ------------ | ------------------------------------------- | --------------------------------------------- |
| Returns      | `[model, attrs]` for `v-model` and `v-bind` | `value`, `errorMessage`, `meta`, handlers     |
| Form context | Required — it comes off a `useForm` result  | Optional; falls back to standalone validation |
| Best for     | The form's own template, native inputs      | A component that binds itself by name         |

`defineField` where the form and the inputs are in one component. `useField` where the input is a
component in its own right, or where the binding needs handlers rather than a `v-model`.

</decision_framework>

---

<patterns>

## Core patterns

### Pattern 1: useForm with defineField

`defineField` returns a tuple: the model for `v-model`, and the attrs carrying the event handlers
that drive validation timing.

```vue
<script setup lang="ts">
const { handleSubmit, errors, defineField } = useForm({
  validationSchema: schema,
  initialValues: { email: "", password: "" },
});

const [email, emailAttrs] = defineField("email");

const onSubmit = handleSubmit(async (values) => {
  await login(values);
});
</script>

<template>
  <input v-model="email" v-bind="emailAttrs" />
  <span v-if="errors.email" role="alert">{{ errors.email }}</span>
</template>
```

Dropping `v-bind="emailAttrs"` leaves the model bound but the handlers unattached, so the field
never blurs and never validates.

Full code: [examples/core.md](examples/core.md) Pattern 1

---

### Pattern 2: useField in a reusable input

The name arrives as a getter so the field re-binds if the prop changes.

```vue
<script setup lang="ts">
const props = defineProps<{ name: string }>();

const { value, errorMessage, handleBlur, handleChange, meta } =
  useField<string>(() => props.name, undefined, {
    validateOnValueUpdate: false,
  });
</script>
```

`validateOnValueUpdate: false` holds validation back to blur, which is what stops errors appearing
while the user is still typing the first character.

Full code: [examples/core.md](examples/core.md) Pattern 3

---

### Pattern 3: Schema through toTypedSchema

The adapter is the boundary: the schema states the rules, and `toTypedSchema` renders them as
VeeValidate errors and as the type of `values`.

```typescript
import { toTypedSchema } from "@vee-validate/zod";

const schema = toTypedSchema(registrationSchema);

const { handleSubmit, errors } = useForm({
  validationSchema: schema,
  initialValues: { email: "", password: "", confirmPassword: "" },
});
```

Every key a cross-field rule compares needs an entry in `initialValues` — a rule reading a key that
is `undefined` is skipped rather than failed, so the form submits as valid.

Full code: [examples/validation.md](examples/validation.md)

---

### Pattern 4: useFieldArray

`fields` carries a `key` and a `value` per entry. The key is the iteration key; the value is what
the inputs bind to.

```vue
<script setup lang="ts">
const { handleSubmit } = useForm({
  initialValues: { users: [{ name: "", email: "" }] },
});

const { fields, push, remove } = useFieldArray("users");
</script>

<template>
  <div v-for="(field, index) in fields" :key="field.key">
    <input v-model="field.value.name" />
    <button type="button" @click="remove(index)">Remove</button>
  </div>
</template>
```

Full code: [examples/arrays.md](examples/arrays.md)

---

### Pattern 5: Server-side errors

`setErrors` takes a record of field names to messages, and `setFieldError` sets one. Both put a
server's verdict where the field's own error would go, so the template needs no separate branch.

```typescript
const onSubmit = handleSubmit(async (values) => {
  try {
    await createUser(values);
  } catch (error) {
    const fieldErrors = extractFieldErrors(error);
    if (fieldErrors) {
      setErrors(fieldErrors);
    } else {
      setFieldError("email", "Could not create the account");
    }
  }
});
```

The next validation run clears them — a server error persists only until the field it names changes,
which is why a rejected submission needs the message re-set rather than remembered.

---

### Pattern 6: Form meta

`meta` aggregates the fields: `valid`, `dirty`, `touched` and `pending` describe the form as a whole.

```vue
<template>
  <button :disabled="!meta.valid || !meta.dirty || isSubmitting">
    {{ isSubmitting ? "Saving..." : "Save" }}
  </button>
  <p v-if="meta.dirty">You have unsaved changes</p>
</template>
```

After a successful save, `resetForm({ values: saved })` makes the saved data the new baseline, which
is what returns `meta.dirty` to false.

Full code: [examples/core.md](examples/core.md) Pattern 4

</patterns>

---

<red_flags>

## Red flags

**Breaks at runtime:**

- A schema handed to `validationSchema` without `toTypedSchema()` — it is accepted and then never
  validates, so the form submits whatever it holds.
- `props.name` passed to `useField` instead of a getter — the field binds to the name captured at
  setup and ignores every later change.
- `initialValues` missing a field array — the composable iterates `undefined` and throws.
- An array index as the `useFieldArray` iteration key — Vue matches the wrong entries, so removing
  a middle row shifts every value below it up one.
- `resetForm(data)` in place of `resetForm({ values: data })` — the argument shape is wrong, and the
  reset takes no effect rather than reporting.

**Surprising behaviour:**

- `errors` holds the first error per field; `errorBag` holds all of them as arrays. A password rule
  set showing one unmet requirement at a time is reading the wrong one.
- `meta.valid` is false on the first render, before validation has run — a submit button gated on it
  alone starts disabled.
- `keepValuesOnUnmount` defaults to false, so a field that unmounts loses its value. Set it true for
  anything that hides fields between steps.
- Nested fields address by dot notation (`defineField("user.profile.name")`) and array items by
  brackets (`errors["items[0].name"]`) — the two are not interchangeable.
- A computed schema must be passed as the computed itself, not `.value` — unwrapped, the form binds
  to one snapshot and stops tracking it.
- `validateOnValueUpdate` left on validates every keystroke, including the first.
- Errors shown without checking `meta.touched` appear before the user has reached the field.

Worked before/after code for the most common of these is in [reference.md](reference.md).

</red_flags>