Troubleshooting
Common issues — symptom first, fix second.
"My field doesn't validate"
Three independent causes.
The schema doesn't include the field. An .optional() wrapper
without an inner refinement accepts anything. Double-check the
schema is what you think it is.
You're in strict: false and watching validate(). Lax mode
strips refinements during default-values derivation so the form
mounts with empty values without failing. Refinements re-apply on
submit. If you want validate() to fire refinements immediately,
drop the strict: false opt-out — strict: true is the default.
The path doesn't match the schema. 'items.0.name' and
['items', 0, 'name'] canonicalise to the same path. But
['items', '0', 'name'] (string '0') does NOT — emit numbers
when the position is an array index.
"Hydration mismatch after SSR"
Did you call hydrateAttaformState(app, payload) before
app.mount(...)? It has to land before setup runs. See the
SSR recipe.
Non-JSON-safe value in the form? Date, Map, Set, BigInt,
and circular refs don't survive JSON.stringify. Either coerce at
the form boundary (z.date().transform((d) => d.toISOString()))
or use Nuxt's devalue-based payload (automatic under Nuxt).
escapeForInlineScript missing on the bare-Vue side? A form
value containing </script> breaks the inline payload. Wrap your
JSON.stringify(payload) in escapeForInlineScript. Not required
under Nuxt.
"Form from another page leaked state in"
Two components mounted the same key at the same time. Use unique,
stable string literals — not generated values:
// Bad — collides when both instances live.
useForm({ schema, key: `form-${Math.random()}` })
// Good — stable per purpose.
const signupFormKey = 'signup' as const
useForm({ schema, key: signupFormKey })
Mount / unmount cycles are handled automatically — keys only collide when two forms with the same key live concurrently.
In dev, a collision whose schemas disagree on shape surfaces as
a console.warn:
[attaform] Two useForm() calls with key "signup" use
structurally-different schemas. Only the first caller wires the
form; the second caller's schema is silently ignored (shared
"last-write" semantics). …
existing schema fingerprint: …
incoming schema fingerprint: …
If the sharing is intentional (both sites genuinely want the same store), pass the same schema to both. If it's accidental, give one of them a unique key. The warning is dev-only and never fires in production builds.
"Shared-key warning fires for schemas I think are identical"
The fingerprint is a best-effort structural hash. Two known false-positive sources in custom adapters:
- The adapter's
fingerprint()builds a string whose contents depend on a non-deterministic input (e.g. a factory default getter that allocates a new value on every call). Make the factory path collapse to an opaque sentinel. - Two declarations look identical in source but one has a
refinement the other doesn't. Refinements in the Zod adapters
intentionally collapse to
fn:*so most refinement-only deltas don't fire the warning, but shape deltas (wrapping with.optional(),.default(…),.catch(…)) do.
"register('email') returns a never-typed value"
The schema generic couldn't be inferred. Two likely causes:
- Your schema is typed as bare
ZodObjectwithout its concrete shape. Use the literal (z.object({ email: z.string() })) or give the variable a precise type. - You imported
useFormfromattaform(the abstract entry) but passed a zod schema directly. Import fromattaform/zodor/zod-v3instead.
"handleSubmit doesn't run when I submit the form"
As of 0.7, handleSubmit(onSubmit) returns the handler function,
not a Promise. Bind the returned value:
<script setup lang="ts">
const submit = handleSubmit(async (values) => {
await api.signup(values)
})
</script>
<template>
<form @submit.prevent="submit">...</form>
</template>
"v-register on my component does nothing (typing doesn't update the form)"
<MyComponent v-register="..."> works only when the component's
rendered root element is one Vue's directive can bind: <input>,
<textarea>, or <select>. For components whose root is a <div>
/ <label> / styled wrapper, the directive can't read el.value
off the wrapper and skips listener attachment to avoid the bubbled-
write bug — typing into a descendant input goes nowhere.
The fix: call useRegister() in the child's setup and re-bind
v-register onto an inner native element:
<!-- StyledInput.vue -->
<script setup lang="ts">
import { useRegister } from 'attaform'
const register = useRegister()
</script>
<template>
<div class="wrapper">
<input v-register="register" />
</div>
</template>
The dev-mode console warning v-register on <div> is a no-op …
points here. See the components recipe
for the four supported patterns (native root, useRegister,
injectForm for compound components, and the assignKey
escape hatch).
"Submit fails with 'No value supplied' on a field the user can leave blank"
The path is in the form's blankPaths set and bound to a required
schema. Three resolutions, depending on intent:
- The field is genuinely optional. Wrap the schema:
z.string().optional(),z.number().nullable(), orz.string().default(''). Optional / nullable / has-default schemas accept the empty case and don't raise. - The field is required but the consumer wants
''to count as "filled". Supply an explicit default at construction:defaultValues: { email: '' }. The library reads this as "empty string is intentional" and skips the auto-mark for that leaf. - The library should treat a blank field as "user didn't fill
it." Working as intended — the synthesized error
(
code: 'atta:no-value-supplied') prevents silently submitting0/''/falsefor an unfilled required field.
See app-defaults recipe for the
auto-mark rules and the unset sentinel.
"Persisted state is gone after a schema change"
Working as intended. As of 0.12, storage keys carry the schema's
fingerprint (${base}:${fingerprint}). When the schema changes
shape, the fingerprint changes, the old key becomes unreachable,
and the orphan-cleanup pass on the next mount removes it. No
manual version bump needed — it's automatic.
If the schema didn't change shape but state was wiped anyway, the fingerprint is over-sensitive. Common causes in custom adapters:
- The adapter's
fingerprint()mixes function-valued metadata (refinement bodies, transform fns) into the hash without collapsing to a sentinel. Two refines of the same shape produce different hashes; consumers see drafts vanish on every refine edit. Collapse functions to'fn:*'. - The fingerprint includes a timestamp or per-call random ID. It must be a pure function of the schema's structure.
If you genuinely need to invalidate drafts without changing the
schema (e.g. shipping a security fix that requires fresh state),
call form.clearPersistedDraft() on mount or evict the registry
entry programmatically.
"I see prev?.first ?? '' getting flagged as redundant"
Working as intended. As of 0.12, the path-form setValue callback
receives a fully-defaulted prev — the runtime calls
getDefaultAtPath on missing slots before invoking the callback,
so consumer code can read prev.first.toUpperCase() directly. Drop
the optional chain.
Whole-form callback prev is WriteShape<Form>. Array reads
(prev.posts[5]) carry | undefined from your tsconfig's
noUncheckedIndexedAccess: true — narrow with ?. or a guard.
Iteration (for (const p of prev.posts), prev.posts.map(...))
keeps the strict element type; that's the flag's intended scope.
"form.values.posts[0].title.toUpperCase() started type-erroring"
Working as intended. Once a read path crosses an array index, the
result carries | undefined — the runtime can return undefined for
out-of-bounds reads, so the type tracks that. Narrow with optional
chaining:
form.values.posts[0]?.title?.toUpperCase() ?? ''
Or read from form.fields.posts[0].title.value if you also want
the per-field flags (dirty, errors, touched) alongside the
value. The same | undefined taint applies; narrow the same way.
Tuple positions stay strict — out-of-bounds is a type-system error on tuples, not a runtime case.
"TS was happy but form.values.contacts[42].name crashed at runtime"
Try enabling noUncheckedIndexedAccess: true in your tsconfig — it
makes arr[N] reads return T | undefined so the type system
catches stale indices. See
README → Recommended tsconfig.
"Focus jumped to a field I didn't expect on submit"
focusFirstError (and the onInvalidSubmit: 'focus-first-error'
policy) targets the visually-first errored field — DOM-tree
order via compareDocumentPosition. If your template renders
fields in a different order than the schema declares them, the
field rendered above another wins regardless of declaration order.
Caveat: CSS order: flexbox/grid reordering is NOT respected.
A child with order: -1 appears visually first but stays in its
DOM-tree position, and the focus algorithm uses DOM-tree position.
The tradeoff is intentional — visual-order via
getBoundingClientRect would force sync layout per comparison and
break under display: none. If you genuinely need CSS-order:
awareness, file an issue with a concrete repro.
Scope is per useForm() callsite: two useForm({ key }) calls
sharing a key (sidebar + main) each focus only their own
registered elements. Children reaching the form via injectForm()
inherit their ancestor's instance ID, so parent-submit still
focuses inputs registered by deep children.
"Undo brought back stale field errors"
By design. An undo snapshot captures the form value AND the errors
that were live at the time. If you want an undo to land on a
clean error state, call form.clearFieldErrors() right after
undo().
"My custom adapter's errors have the wrong path"
validateAtPath returns ValidationError[] with path: (string | number)[]. Whatever your adapter emits is what downstream code
uses. Mismatches between your adapter's path format and the rest
of the app's usually stem from:
- Mixing string / number types for array indices. Emit
['items', 0, 'name'](number0), not['items', '0', 'name'](string). - Paths relative to a sub-schema leaking through when the caller asked for an absolute path — re-stamp error paths with the field prefix before returning.
"My custom adapter is missing code on its ValidationErrors"
Every ValidationError carries a required code: string. Pick a
stable scope prefix for your adapter (e.g. 'mylib:') and forward
the underlying issue's code under it:
return {
errors: result.issues.map((issue) => ({
path: issue.path,
message: issue.message,
formKey: '',
code: `mylib:${issue.code ?? 'unknown'}`,
})),
// ...
}
See the custom-adapter recipe for
the full contract including isRequiredAtPath (used by the blank
validation augmentation) and getSlimPrimitiveTypesAtPath (used
by the slim-primitive write gate).
"Dev warnings don't fire — am I in production?"
The library uses a __DEV__ flag that resolves from
process.env.NODE_ENV !== 'production' at module load. Standard
bundlers (Vite, Webpack, Rollup with @rollup/plugin-replace)
inline process.env.NODE_ENV at build time so the flag becomes
a constant the compiler can dead-code-eliminate.
If you're importing the library directly from a browser-native
ESM CDN (esm.sh, Skypack, unpkg) without a bundler, process
is undefined and __DEV__ is permanently false — every dev-mode
warning is silenced even though you're clearly in development.
The library works correctly; only the diagnostic surface degrades.
The fix is to put a bundler in your pipeline (or use a CDN that
serves a bundled distribution). For production apps, this is
already the case; for prototype-style CDN imports, it's a
deliberate trade-off: no process.env.NODE_ENV replacement, no
dev warnings.
Still stuck?
Reproduce in test/ — vitest is configured, jsdom is set up for
directive work, and the Nuxt fixture is one command away (pnpm test -- test/ssr.test.ts). If your repro passes, the bug is
likely in your app wiring; if it fails, it's probably worth a PR.