attaform
The framework-agnostic core. Use this if you're bringing your own schema library or wiring SSR by hand.
import {
createAttaform,
useForm, // re-export of useAbstractForm
injectForm,
useRegistry,
renderAttaformState,
hydrateAttaformState,
escapeForInlineScript,
vRegister,
canonicalizePath,
parseApiErrors,
} from 'attaform'
createAttaform(options?)
The Vue plugin. Install once per app.
createApp(App).use(createAttaform()).mount('#app')
Options:
| Field | Type | Description |
|---|---|---|
override | boolean | Force isSSR to true / false. Auto-detected otherwise. |
devtools | boolean | Enable the Vue DevTools plugin. Default true. See recipe. |
defaults | AttaformDefaults | App-level option defaults applied to every useForm call. See recipe. |
useForm<Form>({ schema, key, ... })
Schema-agnostic. Takes any AbstractSchema<Form, Form> — wrap a
Valibot schema, ArkType schema, or a hand-rolled validator with
a custom adapter. The Zod subpaths
are pre-made wrappers over this. For options, see
attaform/zod.
injectForm<Form>(key?)
Reach the nearest ancestor's form (no key) or reach any form by its
key. Type-identical return to useForm. See
recipe.
Resolution rules (no-key form):
- Closest ambient ancestor wins.
- Only anonymous
useForm()(nokey) fills the ambient slot; keyed forms are reachable only viainjectForm(key). - No ambient ancestor → returns
null(dev-mode warn). - Inherits the resolved ancestor's
formInstanceId.
Resolution rules (keyed form): registry lookup by string key, independent of component-tree position.
useRegistry()
Returns the current app's AttaformRegistry. Must be called inside
a component's setup().
renderAttaformState(app) → SerializedAttaformState
Server-side: serialize every form in the app to a plain object safe
for JSON.stringify. Pair with hydrateAttaformState on the
client.
hydrateAttaformState(app, payload)
Client-side: rehydrate forms from the serialized payload. Call
before app.mount(...).
escapeForInlineScript(json) → string
Takes a JSON string and escapes the characters that would let a
form value break out of an inline <script> tag: <, >, &,
U+2028, U+2029. Pair with renderAttaformState when hand-rolling
SSR; Nuxt handles it for you via devalue.
const payload = escapeForInlineScript(JSON.stringify(renderAttaformState(app)))
// `<script>window.__STATE__ = ${payload}</script>` is safe to inline.
vRegister
The v-register directive. Registered automatically by
createAttaform; exported for consumers installing directives
manually.
Bind to a native input, select, textarea, checkbox, or radio:
<input v-register="form.register('email')" />
<select v-register="form.register('country')">...</select>
Or to a custom component whose root is not a native input —
useRegister() in the child reads the parent's binding so you can
re-bind v-register onto an inner native element. When the
wrapper's root is the input itself, Vue's attribute fallthrough
handles it and useRegister is unnecessary.
<!-- Parent -->
<MyField label="Email" v-register="form.register('email')" />
<!-- MyField.vue (root is <label>, not <input>) -->
<script setup lang="ts">
import { useRegister } from 'attaform'
defineProps<{ label: string }>()
const register = useRegister()
</script>
<template>
<label class="field">
<span>{{ label }}</span>
<input v-register="register" />
</label>
</template>
Modifiers
v-register mirrors Vue's v-model modifier semantics, scoped per
element type. Modifier names are typed — a typo (v-register.lazi)
is a TypeScript error, not a silent runtime no-op.
| Element | Modifier | What it does |
|---|---|---|
<input type="text">, <input type="number">, <textarea> | .lazy | Write on change (blur) instead of input. Disables IME composition handlers — composition events don't gate writes. |
<input type="text">, <input type="number">, <textarea> | .trim | Strip leading/trailing whitespace on blur. While the user is typing, the model holds the raw input (whitespace included); on change the value is trimmed once and written to both model and DOM. Combine with .lazy to skip the mid-typing writes entirely. |
<input type="text">, <input type="number">, <textarea> | .number | Cast via parseFloat before writing; values that can't be parsed pass through unchanged. Auto-applied for <input type="number"> — explicit .number is redundant. |
<select> | .number | Cast each selected option's value via parseFloat before writing. Mirrors Vue's v-model on <select>. |
<input type="checkbox">, <input type="radio"> | (none) | No modifiers — Vue's v-model doesn't define any here either. |
Combine freely on text/textarea: <input v-register.lazy.number="form.register('age')" />.
When the slim-primitive gate rejects a write produced by a modifier
cast (e.g. .number × 'abc' against a z.number() slot — the
non-parseable string passes through looseToNumber unchanged), the
directive's listener completes silently and the DOM keeps the user's
input. The form state stays at its previous value. Field-level
validation will surface a refinement error on the next render.
Custom assigners — @update:registerValue
Replaces the directive's default "DOM event → extract value →
rv.setValueWithInternalPath(value)" bridge. The handler receives
the post-extraction value plus the RegisterValue and decides what
(if anything) reaches form state.
<script setup lang="ts">
import type { RegisterValue } from 'attaform'
const form = useForm({ schema, defaultValues: { username: '' } })
function uppercaseAssigner(value: unknown, rv: RegisterValue): void {
rv.setValueWithInternalPath(String(value ?? '').toUpperCase())
}
</script>
<template>
<input v-register="form.register('username')" @update:registerValue="uppercaseAssigner" />
</template>
Modifier extraction runs first — .number gives you a number,
.trim the trimmed string, <input type="checkbox"> the boolean.
Four patterns:
- Transform — call
rv.setValueWithInternalPath(normalised). - Reject — skip the call; the keystroke drops entirely (distinct from validation errors, which accept then flag).
- Side-effect + default — log / analytics, then call through.
- Redirect — write to a different field or external store.
Handler signature:
(value: unknown, registerValue: RegisterValue) => boolean | undefined.
Return false to flag a rejected write; undefined / void is
success. Use only on <input>, <select>, <textarea> roots — for
non-form roots see useRegister() or assignKey (Web Components).
The handler can be a top-level function outside setup() since
rv is supplied by the directive. Multiple listeners on the same
element receive (value, rv) in registration order.
Transforms — register(path, { transforms: [...] })
A pipeline of pure functions for normalizing user input. Composed
left-to-right; runs inside the directive's assigner across every
v-register element variant.
import type { RegisterTransform } from 'attaform'
const trim: RegisterTransform = (v) => (typeof v === 'string' ? v.trim() : v)
const lowercase: RegisterTransform = (v) => (typeof v === 'string' ? v.toLowerCase() : v)
const rv = form.register('email', { transforms: [trim, lowercase] })
<input v-register="rv" />
<!-- type " Foo@BAR.com ", form receives "foo@bar.com" -->
RegisterTransform is (value: unknown) => unknown — generic-erased
so a personal library of transforms plugs into any register() slot.
Write defensive bodies that no-op on type mismatch.
Pipeline ordering: transforms run after modifier extraction, before the assigner writes to form state.
DOM event → modifier cast → transforms[0] → … → transforms[n] → assigner
Combine freely: <input v-register.lazy.number="register('age', { transforms: [clamp(0, 99)] })">.
Scope. Transforms apply to user-input via the directive only —
NOT to setValue, reset, hydration, SSR replay, or markBlank().
For programmatic writes, compose transforms at the call site:
form.setValue('email', lowercase(trim(rawValue))).
With @update:registerValue. The override receives the
post-transform value as its first arg. If you want the raw
extracted value, don't register transforms.
Failure mode. Must be sync. On throw OR Promise return: the
pipeline aborts, form state is unchanged, the assigner returns
false, the DOM reverts via the :value binding, and a
console.error is logged. Dev mode includes the path, transform
index, transform .name, and remediation hint; prod logs a fixed
string only. A throw on one keystroke doesn't poison subsequent
keystrokes or other fields.
Transforms cover normalization. @update:registerValue covers
control (rejection-with-side-effect, redirection, custom DOM
mutation).
canonicalizePath(input) → { segments, key }
Normalise a dotted-string or array path into a structured Path
plus a stable PathKey. Use when building custom adapters.
parseApiErrors(payload, options) → ParseApiErrorsResult
Pure transformation: takes a server response in the common shapes
({ error: { details } }, { details }, or a raw { path: entry }
record) and returns { ok, errors, rejected? }. Pair with
form.setFieldErrors(result.errors) to apply.
Wire format. Two entry shapes:
- Structured —
{ message: string, code: string }.codeforwards onto the producedValidationError. - Bare string — synthesized into
{ message, code: defaultCode }.defaultCodedefaults to'api:unknown'.
A field's value may be a single entry, an array, or a mix.
{
"error": {
"details": {
"email": { "message": "taken", "code": "api:duplicate-email" },
"password": [{ "message": "too short", "code": "api:min-length" }, "must include a number"],
"username": ["Username is reserved."],
"items.0.name": { "message": "blank", "code": "api:blank" },
"": { "message": "form-level failure", "code": "api:form" },
},
},
}
const result = parseApiErrors(response, {
formKey: form.key,
// Stamp every bare-string entry with a custom code (default 'api:unknown'):
defaultCode: 'api:server-validation',
// Optional caps for untrusted gateway-passthrough payloads:
maxEntries: 200, // default 1000
maxPathDepth: 8, // default 32
})
if (result.ok) form.setFieldErrors(result.errors)
else console.warn('Bad payload:', result.rejected)
Half-structured entries ({ message } with no code, or { code }
with no message) are still rejected — those signal a server bug
(the wire shape was trying to be structured) and shouldn't be
silently coerced.
See server-errors recipe for the full pattern.
Error codes
Every ValidationError carries a required code: string for stable
machine identification. Convention is <scope>:<kebab-case>:
| Scope | Owner | Examples |
|---|---|---|
atta: | Library core | atta:no-value-supplied, atta:adapter-threw, atta:path-not-found |
zod: | Zod adapter | zod:too_small, zod:invalid_format, zod:custom (forwarded from issue.code) |
| consumer | Your app / backend | api:duplicate-email, auth:expired-token, myapp:account-locked |
The library exports AttaformErrorCode for branching on internal codes:
import { AttaformErrorCode } from 'attaform'
// or 'attaform/zod' / 'attaform/zod-v3'
if (error.code === AttaformErrorCode.NoValueSupplied) {
// user opened the form and hasn't filled this field yet
}
if (error.code.startsWith('zod:')) {
// schema-level validation failure
}
zod: codes are computed inline (no enum) since Zod's code list
evolves. String-match the prefix to handle "any zod error" generically,
or check exact codes for fine-grained branching.
The library never invents consumer-side codes — they originate in your
backend payload (via parseApiErrors) or in setFieldErrors /
addFieldErrors calls you make directly. Pick a prefix and stay
consistent across your app.
unset
A brand-typed sentinel symbol used to mark a primitive leaf as
displayed-empty while storage holds the schema's slim default
(0 for z.number(), '' for z.string(), false for
z.boolean(), 0n for z.bigint()).
import { unset, useForm } from 'attaform/zod'
import { z } from 'zod'
const form = useForm({
schema: z.object({ income: z.number() }),
defaultValues: { income: unset }, // input renders blank, storage = 0
})
// Programmatic clear — same semantic as the user backspacing the field.
form.setValue('income', unset)
// Restore-with-blanks via reset.
form.reset({ income: unset })
Three places accept the sentinel:
defaultValues— every primitive leaf can beunset. The library walks the payload at construction and adds the leaf's path to the form'sblankPathsset.setValue(path, unset)— translated at the API boundary; storage gets the slim default withblank: truemeta.reset({ … })— same translation; the post-reset state becomes the new dirty=false baseline.
Auto-mark on construction. Every primitive leaf the consumer
didn't supply in defaultValues is auto-marked blank. To opt a
leaf out, supply a non-unset value (defaultValues: { email: '' }).
Auto-mark recurses through nested objects, NOT arrays. Hydration
(persisted draft, SSR payload) overrides — the hydrated blankPaths
list is authoritative.
Submit / validate honor the sentinel. A blank path bound to a
required schema raises "No value supplied" during handleSubmit /
validate*. Optional / nullable / has-default schemas accept the
empty case.
The directive's input listener auto-marks numeric inputs on empty
DOM; strings and booleans require explicit unset (DOM state alone
doesn't carry "user-cleared" intent).
Introspection. form.fields.<path>.blank per-path;
form.blankPaths.value (frozen ReadonlySet<PathKey>) for bulk.
isUnset(value) is the runtime guard; Unset the type-level
brand.
Other exports
parseDottedPath(s)— string →Segment[]assignKey—unique symbolused to install a custom assigner on a v-register-bound element. For most cases prefer the@update:registerValuelistener (see Custom assigners); reach forassignKeyonly when you need pre-mount installation (typically Web Components).isRegisterValue(x)— type guard for the objectregisterreturnsRegisterTransform—(value: unknown) => unknown— type alias for entries inregister(path, { transforms: [...] }). Generic-erased so a personal library of transforms works across any path type; see Transforms.ROOT_PATH/ROOT_PATH_KEY— the empty path and its keyPARSE_API_ERRORS_DEFAULTS—{ maxEntries: 1000, maxPathDepth: 32, maxTotalSegments: 10000 }constantAnonPersistError/InvalidPathError/OutsideSetupError/RegistryNotInstalledError/ReservedFormKeyError/SensitivePersistFieldError/SubmitErrorHandlerError— error classes