Domphy

Core Concepts

Architecture

@domphy/form is a 1-1 port of @tanstack/form-core — the same FormApi, FieldApi, and ValidationLogic classes, byte-identical. The Domphy adapter (@domphy/form/domphy) wraps them with reactive handles that plug into Domphy's listener system.

FormApi (TanStack core)
   ↓ wrapped by
createForm() adapter        ← you call this
   ↓ returns
form handle {
  values(l), state(l), canSubmit(l)   ← reactive reads
  field(name, opts)                   ← create a field handle
  handleSubmit(), reset()             ← imperative actions
  form                                ← underlying FormApi
}

Form state lifecycle

Initial            → user types →    Dirty
                   → user blurs →    Touched
                   → submit once →   isSubmitted = true (validates all)
                   → submitting →    isSubmitting = true
                   → submit done →   isSubmitting = false

Key flags:

FlagDescription
isSubmittedtrue after first submit attempt
isSubmittingtrue during async submission
isValidtrue when no field has errors
canSubmittrue when valid AND not submitting
isPristinetrue when no field has been changed
isDirtytrue when any field has changed from defaultValues

Field state lifecycle

Initial
  → onChange →  isDirty, value updates, onChange validators run
  → onBlur  →  isTouched = true, onBlur validators run
  → onSubmit → all validators run regardless of touched state

Field meta:

interface FieldMeta {
  isTouched: boolean
  isDirty: boolean
  isPristine: boolean
  isBlurred: boolean
  isValidating: boolean   // async validator in-flight
  touchedAt: number | null
  errors: unknown[]
  errorMap: Partial<Record<"onChange"|"onBlur"|"onSubmit"|"onMount", unknown>>
}

Validation execution order

  1. onChange validators — every keystroke
  2. onChangeAsync validators — debounced after onChange
  3. onBlur validators — when field loses focus
  4. onBlurAsync validators — async on blur
  5. onMount validators — once on field creation
  6. onSubmit validators — only on handleSubmit()
  7. onSubmitAsync validators — async on submit

Earlier validators block later ones in the same "timing group" — if onChange returns an error, onChangeAsync does not run until the sync error clears.

The adapter pattern

The Domphy adapter converts FormApi subscriptions to listener-based reactivity:

// Inside createForm() — simplified
function createForm<T>(options) {
  const api = new FormApi(options)
  api.mount()

  return {
    values: (l) => {
      // Subscribe listener to FormApi state
      api.subscribe(() => l?.notify())
      return api.state.values
    },
    field: (name, fieldOptions) => createFieldHandle(api, name, fieldOptions),
    handleSubmit: () => api.handleSubmit(),
    // ...
  }
}

This means form.values(l) re-renders only when FormApi.state.values changes — not on every keystroke unless the element's listener reads from the values state.

form.field() creates a stable handle

Unlike React hooks, form.field() can be called anywhere — it creates a field handle that persists for the form's lifetime:

const form = createForm<{ email: string; name: string }>({
  defaultValues: { email: "", name: "" },
  onSubmit: ({ value }) => submit(value),
})

// Create once — these are stable objects
const emailField = form.field<string>("email", {
  validators: { onChange: ({ value }) => value.includes("@") ? undefined : "Invalid" },
})
const nameField = form.field<string>("name", {})

Do not call form.field() inside a reactive render function (it re-registers the field on each render). Create fields in module scope or component setup.

Form options

const form = createForm<T>({
  defaultValues: T,                      // required — initial field values
  onSubmit: ({ value, formApi }) => {},  // called when form is valid and submitted
  onSubmitInvalid: ({ value, formApi }) => {},  // called on submit when invalid
  validators: {                          // form-level validators
    onChange: ({ value }) => string | undefined,
    onSubmit: ({ value }) => string | undefined,
  },
  asyncDebounceMs: 200,                  // global debounce for all async validators
  defaultState: Partial<FormState>,      // override initial state flags
})

Reading form state

// Reactive reads (pass listener l)
form.values(l)         // T — current values
form.state(l)          // FormState<T> — full state including meta
form.canSubmit(l)      // boolean
form.isSubmitting(l)   // boolean
form.isValid(l)        // boolean
form.isSubmitted(l)    // boolean
form.isDirty(l)        // boolean

// Non-reactive (no listener) — snapshot
form.form.state.values
form.form.getFieldValue("email")

Field handle API

const field = form.field<string>("name", options)

// Reactive
field.value(l)     // string — current value
field.errors(l)    // unknown[] — current errors
field.meta(l)      // FieldMeta — full field state

// Imperative
field.handleChange(newValue)          // update + run onChange validators
field.handleBlur()                    // mark touched + run onBlur validators
field.setValue(newValue)              // update without running validators
field.pushValue(item)                 // for array fields — push
field.removeValue(index)              // for array fields — remove
field.swapValues(indexA, indexB)      // for array fields — swap
field.api                             // underlying FieldApi