App-wide defaults
One plugin call sets the defaults every
useFormin the app inherits. Set the convention once, override per-form when a particular surface needs something different.
- Category
- Plugin
- Bare Vue
app.use(createAttaform({ defaults }))- Nuxt
nuxt.config.ts → attaform.defaults- Resolution
- per-form > app-level > library default
This page is code-only; createAttaform runs at app boot, before any form mounts. The demos throughout the rest of the docs implicitly use Attaform's built-in defaults (validateOn: 'change', debounceMs: 0, etc.); this page shows how to set your own.
Setup
Bare Vue 3
// main.ts
import { createApp } from 'vue'
import { createAttaform } from 'attaform'
import App from './App.vue'
createApp(App)
.use(
createAttaform({
defaults: {
debounceMs: 100,
onInvalidSubmit: 'focus-first-error',
},
})
)
.mount('#app')
Nuxt 3 / 4
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['attaform/nuxt'],
attaform: {
defaults: {
debounceMs: 100,
onInvalidSubmit: 'focus-first-error',
},
},
})
The Nuxt module surfaces the same AttaformDefaults type under attaform.defaults in your nuxt.config.ts; no separate createAttaform call needed.
Resolution order
Per-form > app-level > library default. Per-form always wins.
useForm({ … }) > createAttaform({ defaults }) > library default
Merge semantics
Every option resolves independently. Set anything once at the app level, override anything per-form without losing the rest:
// Plugin side
createAttaform({
defaults: { validateOn: 'change', debounceMs: 100 },
})
// useForm calls
useForm({ schema })
// → validateOn: 'change', debounceMs: 100 (both inherited)
useForm({ schema, validateOn: 'blur' })
// → validateOn: 'blur'; debounceMs is dropped
// (the TS-level ValidateOnConfig discriminated union rejects
// debounceMs when validateOn isn't 'change'; paired with 'blur'
// is a compile-time error)
useForm({ schema, debounceMs: 25 })
// → validateOn: 'change' (app-level), debounceMs: 25 (per-form wins)
validateOn and debounceMs are flat top-level fields. The ValidateOnConfig discriminated union enforces that debounceMs is only valid when validateOn is 'change' (or omitted); pairing it with 'blur' / 'submit' doesn't compile.
What's supported
AttaformDefaults covers the form-shaping options. Every key is optional; this is the whole set, annotated with the real exported type so the list stays honest:
import { createAttaform, makeDefaultDisplayState } from 'attaform'
import { historyPlugin } from 'attaform/history'
import type { AttaformDefaults } from 'attaform'
const defaults: AttaformDefaults = {
strict: true,
validateOn: 'change',
debounceMs: 100,
onInvalidSubmit: 'focus-first-error',
history: historyPlugin(),
rememberVariants: true,
disabled: false,
coerce: true,
autoAria: true,
getDisplayState: makeDefaultDisplayState({ showDelay: 120, minVisible: 120 }),
maxRecursionDepth: 64,
}
const attaform = createAttaform({ defaults })
getDisplayState resolves field.displayState and its show* projections: the centralized "what should this field surface right now?" reducer, returning one of idle, pending, error, or success. Set it once at the app level so every form follows the same convention. To keep the default behavior but retune the anti-flash spinner timing, pass makeDefaultDisplayState({ showDelay, minVisible }). See Display state and showing errors for the full contract.
history takes a historyPlugin() instance from the attaform/history entry. One instance set here is a shared configuration, and every form still gets its own independent undo/redo chain. See Undo & redo.
disabled freezes every form's data app-wide, which is the shape an app-level read-only or impersonation mode wants. It takes the same boolean | ref | computed | getter a per-form call takes, so one reactive source can flip the whole app at once. See disabled.
autoAria: false hands ARIA back to your own markup for every form, rather than per call site. Authored attributes always win regardless, so reach for this only when a design system owns the whole accessibility layer. See v-register.
What's NOT supported (and why)
schema: per-form by definition; a cross-form schema doesn't make sense.key: per-form identity.defaultValues: per-form initial values.
Per-form defaultValues
Global defaults shape options like strict and validateOn. Per-form initial values live on each useForm({ defaultValues }) call.
Three patterns:
import { unset } from 'attaform'
// 1. Plain values: explicit defaults flow into storage and the form
// is not blank for those leaves.
useForm({ schema, defaultValues: { email: 'me@example.com', count: 10 } })
// 2. Omit defaultValues entirely: every NUMERIC primitive leaf
// (number, bigint) is auto-marked blank at construction. Storage
// holds the schema's slim defaults; the form displays empty;
// `form.errors.<path>` reactively carries 'No value supplied' for
// required schemas. Strings and booleans are NOT auto-marked
// because their slim defaults match what the DOM shows natively.
useForm({ schema })
// 3. Mark any path as `unset`: leaf, container, or the whole form.
// The runtime writes the schema's slim value at the path and adds
// every primitive descendant to form.blankPaths.
useForm({ schema, defaultValues: { email: unset, count: 10 } })
useForm({ schema, defaultValues: { profile: unset } }) // container
useForm({ schema, defaultValues: unset }) // root
unset works in setValue('profile', unset) and reset({ email: unset }) identically: same semantic at every position.
Alternative: userland wrapper
If you need defaults but don't want to touch the plugin (third-party component library, opting in only for some forms), wrap useForm in your project:
// composables/useAppForm.ts
import { useForm as attaformUseForm } from 'attaform'
import type { z } from 'zod'
export function useAppForm<S extends z.ZodObject<z.ZodRawShape>>(
schema: S,
defaultValues: z.input<S>
) {
return attaformUseForm({
schema,
defaultValues,
validateOn: 'change',
debounceMs: 100,
})
}
Every useAppForm(schema, defaults) call gets your app-wide settings, and inference still flows from the schema. Forward more options the same way, as named parameters the wrapper assembles into the configuration. Keep the wrapper in this shape rather than typing one passthrough options bag under the generic: TypeScript relates schema: S and defaultValues: z.input<S> to useForm's slots directly, while a whole-bag parameter forces an instantiation cascade that Zod v4's types push past the compiler's depth limit. The plugin-level approach is idiomatic for first-party apps; the wrapper is right when you can't (or shouldn't) influence the plugin config from your call site.
Where to next
- Display state and showing errors: set the
getDisplayStatepredicate app-wide for a consistent feel across forms. - When validation runs:
validateOnanddebounceMsdefaults shape every form in the app.