attaform/zod-v4

Explicit Zod v4 adapter subpath. Use this when you want to pin v4 regardless of what your bundler resolves — handy for non-Vite bundlers (webpack, esbuild standalone, Rollup) where you'd otherwise pay for both adapters via the unified attaform/zod entry's runtime fallback.

Most Vite consumers should import from attaform/zod instead — the attaform/vite plugin rewrites that import to this subpath at build time when zod@^4 is detected, so the same lean bundle ships with less ceremony.

Requires zod@^4. Importing this subpath against zod@3 throws a clear version-mismatch error from the adapter at the first schema parse.

import { useForm, zodAdapter, fieldMeta, withMeta, kindOf, assertZodVersion } from 'attaform/zod-v4'
import type { FieldMetaPayload, ZodKind } from 'attaform/zod-v4'

useForm<Schema>(options)

The primary entry point. Returns a typed reactive surface; see The useForm return value.

const schema = z.object({ email: z.email() })
const form = useForm({ schema, key: 'signup' })

Options:

FieldTypeRequiredDescription
schemaz.ZodTypeyesThe Zod schema describing the form shape.
keystringnoForm identity for injectForm(key), shared state, persistence keys, or DevTools labels. See Keys.
defaultValuesDeepPartial<DefaultValuesShape<Form>>noConstraints applied over schema defaults. Each leaf may be the unset sentinel. See Default values.
strictbooleannoDefault true — defaults that fail the schema seed schemaErrors at construction. false opts out (multi-step wizards, placeholder rows).
onInvalidSubmit'none' | 'focus-first-error' | 'scroll-to-first-error' | 'both'noBehaviour on failed submit. See recipe.
validateOn'change' | 'blur' | 'submit'noWhen per-field validation runs. Default 'change'. See recipe.
debounceMsnumbernoWait after the last write before re-validating. Default 0. Only valid with validateOn: 'change'.
coerceboolean | CoercionRegistrynoDOM-input coercion. Default true (string→number, string→boolean). See recipe.
rememberVariantsbooleannoDefault true — switching back to a discriminated-union variant restores its prior subtree. See recipe.
persistFormStorageKind | FormStorage | { storage, key?, debounceMs?, include?, clearOnSubmitSuccess? }noPersistence config. Per-field opt-in lives at register(). See Persistence config.
historytrue | { max?: number }noEnable undo/redo. See recipe.

Keys

Omit for one-off forms — the runtime allocates a synthetic __atta:anon:<id> via useId(). Pass a string when you need cross-component lookup via injectForm(key), shared state across call-sites, a stable persist storage-key default, or a recognisable DevTools label.

Keys starting with __atta: are reserved for the library's internal synthetic-key namespace; passing one throws ReservedFormKeyError.

Default values

Refinement-invalid leaves that satisfy the slim primitive type at their path (e.g. 'teal' against z.enum(['red','green','blue']), a 4-character string against z.string().min(8)) pass through unchanged so SSR / autosave rehydration can land partial-but-saved state as-is. Wrong-primitive leaves (a number where a string is expected) are still replaced by the schema default.

Each primitive leaf may be the unset sentinel to mark the path displayed-empty at construction.

Persistence config

The form-level option is operational only — backend, key, debounce, error inclusion. Per-field opt-in lives at every register('foo', { persist: true }) call site; this config alone never causes any field to persist.

Three input forms: a string shorthand ('local' / 'session' / 'indexeddb'), a custom FormStorage adapter passed directly, or the full options bag.

Storage keys carry the schema's fingerprint (${base}:${fingerprint}) so schema changes auto-invalidate old drafts. The orphan-cleanup pass on mount sweeps stale-fingerprint entries on the configured backend AND wipes any matching keys on the non-configured standard backends. Malformed payloads are wiped on read.

See persistence recipe for the full pattern.

Schema-attached metadata

Attach labels, descriptions, and placeholders directly to schema fields. Read them off form.fields(path).label / .description / .placeholder / .meta — same surface for leaves and containers.

import { z } from 'zod'
import { fieldMeta, withMeta } from 'attaform/zod-v4'

// Native Zod 4 chain
const A = z.object({
  email: z.email().register(fieldMeta, {
    label: 'Email',
    placeholder: 'you@example.com',
  }),
})

// Helper (works on v3 and v4)
const B = z.object({
  email: withMeta(z.email(), {
    label: 'Email',
    placeholder: 'you@example.com',
  }),
})

Both forms write to the same fieldMeta registry; pick whichever reads naturally. Container schemas register the same way: z.object({...}).register(fieldMeta, { label: 'Pickup address' }).

Resolution order for each field on form.fields(path):

FieldSources
labelregistered labelhumanize(lastSegment) (camelCase / snake_case / kebab-case → Title Case; numeric segments collapse to '')
descriptionregistered descriptionschema.description (Zod's .describe(...)) → undefined
placeholderregistered placeholderundefined
metathe full registered payload, frozen — empty object when nothing has been registered

Setting both .describe('...') and .register(fieldMeta, { description: '...' }) is fine — the registered description wins, and .describe() stays readable for unrelated tooling (JSON-Schema export, etc.).

Custom payload keys. FieldMetaPayload is an interface — extend it via TypeScript module augmentation:

declare module 'attaform/zod-v4' {
  interface FieldMetaPayload {
    tooltip?: string
    icon?: string
  }
}

const schema = z.object({
  email: z.email().register(fieldMeta, { label: 'Email', tooltip: 'For login' }),
})

// template: {{ form.fields.email.meta.tooltip }} → 'For login'

The single fieldMeta registry holds the augmented shape — no fragmentation across consumers.

Reusing a sub-schema at multiple paths. Both registration styles are safe. Zod's registry keys on the schema reference, but the adapter walks the form's schema tree at first lookup and pairs each path with its intended payload — registration order (object literal left-to-right) matches walk order, so the two patterns below both resolve as you'd expect:

const addressSchema = z.object({ line1: z.string(), city: z.string() })

// Native chain — adapter disambiguates by tree-walk order
const form = z.object({
  pickup: addressSchema.register(fieldMeta, { label: 'Pickup address' }),
  delivery: addressSchema.register(fieldMeta, { label: 'Delivery address' }),
})
// form.fields('pickup').label   → 'Pickup address'
// form.fields('delivery').label → 'Delivery address'

// Helper — clones the schema first so each call gets distinct identity
const form2 = z.object({
  pickup: withMeta(addressSchema, { label: 'Pickup address' }),
  delivery: withMeta(addressSchema, { label: 'Delivery address' }),
})

Sharing a schema with IDENTICAL metadata is also fine — common for array elements (every line item shares one lineItemSchema instance and inherits the same per-leaf labels).

zodAdapter(schema)

Lower-level. Returns an AbstractSchema<Form, Form> that wraps a Zod schema. Reach for it only when composing your own useForm-like hook.

kindOf(schema)

Returns the zod kind ('string', 'number', 'object', 'discriminated-union', etc.) for a Zod 4 schema. For advanced adapter work — wrapping register, building per-kind UI, branching on schema shape.

assertZodVersion(schema)

Throws if the installed zod major doesn't match the adapter. Use when wiring custom adapter code that introspects schema internals and would silently misbehave under the wrong version.

type ZodKind

Union of the strings returned by kindOf'string' | 'number' | 'boolean' | 'object' | 'array' | 'tuple' | 'discriminated-union' | 'union' | ….

See also