| name | attaform |
| description | Build type-safe, schema-driven forms in Vue 3 and Nuxt with Attaform (first-class Zod). Use when creating, editing, or debugging a form (inputs, validation, submission, multistep wizards, or SSR) in a project that has the `attaform` package installed. Covers the correct import surface, the useForm handle, the v-register directive, reading validation state, handleSubmit, server-error routing, and wizards. |
| license | MIT |
Building forms with Attaform
Attaform turns a Zod schema into a reactive, typed Vue form: one schema declares the shape, the defaults, the validation, and the per-field errors, and Attaform builds the reactive surface around it. Use this skill when writing or editing a form in a project that depends on attaform.
This file covers the common case end to end. For depth on one area, load the matching file in references/ (indexed at the bottom).
Imports
Everything comes from the attaform barrel:
import {
useForm,
useWizard,
injectForm,
injectWizard,
useRegister,
fieldMeta,
withMeta,
lazy,
} from 'attaform'
- Do not import
useForm from attaform/abstract. That entry is the bring-your-own-adapter escape hatch: it exports useAbstractForm, which needs a schema adapter wired by hand. A Zod project wants useForm from attaform. This mismatch is the single most common first-try mistake.
- Types come from the same barrel:
import type { AnyForm, WizardCtx, Json, ValidationError } from 'attaform'.
attaform/zod is the same surface named explicitly; attaform/zod-v3 and attaform/zod-v4 pin a Zod major. Any of these works; default to attaform.
- In Nuxt with the module installed (
attaform/nuxt), this surface auto-imports and the v-register directive is registered globally, so a component needs no import lines at all. In plain Vite, add the Attaform plugin from attaform/vite plus the auto-import preset it exports.
Build a form
Three moves: hoist the schema, hand it to useForm, bind each input with v-register. Submit through form.handleSubmit.
<script setup lang="ts">
import { useForm } from 'attaform'
import { z } from 'zod'
const schema = z.object({
email: z.email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
})
const form = useForm({ schema, key: 'sign-in' })
const onSubmit = form.handleSubmit(async (values) => {
await fetch('/api/sign-in', { method: 'POST', body: JSON.stringify(values) })
})
</script>
<template>
<form @submit="onSubmit">
<label>
Email
<input v-register="form.register('email')" autocomplete="email" />
<em v-if="form.fields.email.showErrors">{{ form.fields.email.firstError?.message }}</em>
</label>
<label>
Password
<input v-register="form.register('password')" type="password" autocomplete="off" />
<em v-if="form.fields.password.showErrors">{{ form.fields.password.firstError?.message }}</em>
</label>
<button :disabled="form.meta.submitting" type="submit">Sign in</button>
</form>
</template>
Prefer an explicit key (useForm({ schema, key: 'sign-in' })): it makes the form reachable through injectForm('sign-in'), survives parent refactors, and reads clearly at the call site. Anonymous useForm({ schema }) is the fallback for a single throwaway form.
Rules
- Use the form handle. Don't destructure. Keep
const form = useForm(...) and reach for form.register(...), form.setValue(...), form.meta. Destructuring loses the reactive bindings.
- Bind with
v-register; never write through form.values. form.values is a read view and a callable proxy (typeof form.values === 'function'), so schema.safeParse(form.values) compiles but fails at runtime. Read form.meta.valid for validity; call form.values() for a plain snapshot (form.values('a.b') for a subtree).
- Let
handleSubmit do the work. It validates, calls onSubmit with the parsed values, and focuses the first offending field on an invalid submit. No manual focus and no novalidate ceremony. Bind it with @submit, not @submit.prevent: the handler calls event.preventDefault() for you, so .prevent only prevents twice.
- Never disable the submit button on validity. Let the user click; the failed submit focuses the first error and reveals it. Gate only on
form.meta.submitting or !form.meta.dirty. If a field's only guard was the disabled button, move the requirement into the schema (.min(1), .refine(...)). See references/validation.md.
- Read errors through
field.showErrors and field.firstError?.message. The visibility heuristic lives in showErrors; reaching into field.errors[0] bypasses it. The async "checking" indicator is field.showPending.
- Route server errors through
form.setErrors(...) inside the callback. setErrors replaces the whole user-error layer, so call form.clearErrors() at the top of the submit for a fresh attempt. See references/errors.md.
- Banners read
form.meta.firstOwnError, the form's own error bucket, not form.errors([]) (the whole-form aggregate). See references/errors.md.
- Accessibility is automatic.
v-register keeps aria-invalid, aria-busy, aria-required, and aria-describedby in sync and emits them during SSR. Put form.fields.<path>.aria.errorId on the error element so aria-describedby resolves. Author any ARIA attribute yourself to take it over, or pass autoAria: false to opt out.
- Reach with
?. on injected forms. injectForm() and injectWizard() return T | null; chain optional access (form?.register('email')) at every consumption site.
- Put labels on the schema, not the template.
z.string().register(fieldMeta, { label: 'Email' }); read it back through form.fields.email.label (resolved, with a humanized-path fallback).
- Native inputs first. Bind
<input>, <select>, <textarea> with v-register. Reach for useRegister only inside a custom input component. See references/custom-components.md.
v-register alone does binding, SSR value injection, and ARIA. Do not stack @change handlers, reset-signal props, or watchers on top of it. If a control seems to need that scaffolding, the idiomatic shape is being missed.
Wizards
Compose multiple useForm instances under useWizard. Navigation and submission are separate verbs: wizard.tryNext() is the gated Next (validates the active step, advances on a clean pass), and wizard.handleSubmit(onSubmit) validates every step from any position and calls onSubmit once with all values, but never advances.
import { useForm, useWizard } from 'attaform'
const account = useForm({ schema: accountSchema, key: 'account' })
const profile = useForm({ schema: profileSchema, key: 'profile' })
const wizard = useWizard({ steps: [account, 'review', profile] })
const onComplete = wizard.handleSubmit(async (ctx) => {
await fetch('/onboarding', { method: 'POST', body: JSON.stringify(ctx.values) })
})
A step is a useForm reference, a bare string (an informational step with no schema), null / undefined (filtered out), or a function returning one of those. Full patterns, the declarative step registry, and the keyed-injection gotchas live in references/wizards.md.
SSR
SSR is automatic under the Nuxt module or the Vite plugin: field values, checked, and selected are injected into the first paint and ARIA is emitted, with no extra wiring. Confirm any SSR behavior through the real build (curl the rendered HTML), not a bare unit render. The value injection is a build-time compiler transform that a plain Vitest render does not run. See references/ssr.md.
Reference files
Load one when the task reaches its area:
references/wizards.md: multistep flows. The declarative step registry, tryNext vs handleSubmit, using a wizard as a flat multi-form coordinator, and how keyed injectForm / injectWizard resolve.
references/errors.md: routing server and API errors. The ValidationError envelope, setErrors / clearErrors, the own-vs-subtree error axis, banners, and the one-normalizer pattern.
references/custom-components.md: wrapping an input or binding a third-party component. useRegister, the three orthogonal primitives, attribute fallthrough, and the optional form-key prop.
references/ssr.md: debugging SSR and hydration. Why the value injection is a build-time transform, how to confirm it faithfully, and the test traps to avoid.
references/validation.md: designing the schema. Client-is-UX / server-is-truth, keeping closed sets in sync, and why a clearable edit field is a required string.
Going deeper