Skip to content

Forms

IForm

A form wrapper that validates. Point it at any Standard Schema validator — Zod, Valibot, ArkType — or hand it a plain function, and errors land on the matching IFormField by name.

Try submitting it empty, then fix the fields one at a time — each re-validates as you leave it.

With a schema

schema takes any Standard Schema validator. The library ships none, so bring your own.

vue
<script setup lang="ts">
import { z } from 'zod'

const schema = z.object({
  client: z.string().min(1, 'Who is this invoice for?'),
  email: z.string().email('That does not look like an email address.'),
  amount: z.string().min(1, 'Enter an amount.'),
})

const state = reactive({ client: '', email: '', amount: '' })
</script>

<template>
  <IForm :state="state" :schema="schema" @submit="createInvoice($event.data)">

  </IForm>
</template>

The name on each field is a dot-path into the state, so nested objects work: name="address.city" picks up an error the schema reported for that path.

With a function

For rules a schema cannot express — a value that depends on another field, or a check against the server — pass validate instead. It returns an array of errors and may be async. The demo at the top of this page uses one.

ts
async function validate(state) {
  const errors = []

  if (state.due < state.issued)
    errors.push({ name: 'due', message: 'The due date cannot precede the issue date.' })

  if (await referenceTaken(state.reference))
    errors.push({ name: 'reference', message: 'That reference is already used.' })

  return errors
}

schema and validate can be used together — both run, and their errors merge.

When it validates

validateOn controls re-validation of a field after the first submit. Submitting always validates everything, whatever this is set to.

vue
<!-- The default. -->
<IForm :state="state" :schema="schema" :validate-on="['blur', 'change']" />

<!-- Stricter: check as they type. -->
<IForm :state="state" :schema="schema" :validate-on="['input']" />

Validating on input marks a field invalid while it is still being typed, which is why blur is in the default instead.

Disabling

disabled disables every control inside — useful while a submit is in flight.

vue
<IForm :state="state" :schema="schema" :disabled="saving" @submit="save">

  <IButton type="submit" :loading="saving">
    Save
  </IButton>
</IForm>

Props

PropTypeDefaultDescription
stateobjectRequired. The reactive object the fields write to
schemaStandard SchemaAny compliant validator
validate(state) => FormError[] | Promise<FormError[]>Your own rules; merges with schema
validateOn('blur' | 'input' | 'change')[]['blur', 'change']When a field re-validates
disabledbooleanfalseDisables every control inside
classstringClasses on the <form>

Events

EventPayloadWhen
submit{ data }Validation passed
errorFormError[]Validation failed
ts
interface FormError {
  /** Dot-path into the state; matches a field's `name`. */
  name?: string
  message: string
}

An error with no name belongs to the form rather than a field — a failed request, say. Render those yourself from the error event.

Without the markup

useForm() is the same validation logic without the markup, and useFormField() is how a control of your own joins the wiring of the field around it.