DOM Connect Bridge
The DOM bridge lets you link any HTMLElement directly to a form field path without going through framework reactivity. This is the foundation for zero-rerender uncontrolled field patterns.
form.connect(path, element, options?)
interface ConnectOptions {
/**
* When true, the field's value is retained in getPayload() even after the
* element is removed from the DOM. Use this for fields that unmount between
* wizard steps but whose value must still be submitted.
* @default false
*/
persist?: boolean
/**
* Optional formatter applied to the raw value before it is written to
* element.value. Useful for phone number masking, date formatting, etc.
*/
format?: (val: string) => string
/**
* Overrides the form-level `validationMode` for this specific element.
* Useful when one field needs different validation timing than the global default.
*
* @example
* // Validate this field on blur even if the form default is 'onChange'
* form.connect('email', emailEl, { validateOn: 'onBlur' })
*/
validateOn?: ValidationMode
}
form.connect(
path: string,
element: HTMLElement,
options?: ConnectOptions
): () => voidconnect stores a WeakRef<HTMLElement> in an internal connectionRegistry keyed by path. The returned function disconnects the element and — if persist is false — clears the field's state.
Basic Usage
connect wires up input and blur event handlers on the element automatically. You do not need to add your own event listeners — the bridge handles value syncing and validation according to the form's validationMode (default: 'onTouched').
const emailInput = document.getElementById('email') as HTMLInputElement
// Connect the input — event wiring is done automatically by the bridge
const disconnect = form.connect('email', emailInput)
// Disconnect when the element is removed (e.g. component teardown)
disconnect()To override the validation trigger for one element without changing the global config, pass validateOn:
// Validate this field on blur only, regardless of the form's validationMode
const disconnect = form.connect('email', emailInput, { validateOn: 'onBlur' })Persisted Fields (Multi-Step Wizard)
// Step 1 field — keep the value even when the element is removed from the DOM
form.connect('personalInfo.firstName', firstNameInput, { persist: true })
form.connect('personalInfo.lastName', lastNameInput, { persist: true })
// On step 2, the step-1 inputs are unmounted but their values survive
// because persist: true added them to persistedPaths.Format Option
form.connect('phone', phoneInput, {
format: (raw) => {
const digits = String(raw).replace(/\D/g, '')
if (digits.length <= 3) return digits
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`
},
})Automatic Cleanup via MutationObserver
A single MutationObserver on document.body is created lazily on the first call to connect. It fires on every childList mutation with subtree: true. When a node is removed:
- The observer iterates
connectionRegistryentries. - For each entry, it calls
ref.deref(). If the element isnull(GC'd) or is no longer connected to the document, the entry is pruned. - Unless the path is in
persistedPaths, itserrors,touched, anddirtyentries are cleared and subscribers are notified.
This means you never need to manually clean up connections in most framework component teardowns — the observer handles it for you. However, calling disconnect() explicitly is still recommended because it runs synchronously, whereas the observer fires asynchronously after the DOM mutation.
form.getPayload()
form.getPayload(): Partial<T>Returns a partial values object containing only the paths that are:
- Currently connected to a live DOM element, or
- Connected with
persist: true(even if the element has since been removed)
This is the recommended way to collect form data for submission because it excludes any fields that are conditionally hidden and not persisted — preventing stale or irrelevant data from reaching your API.
submitButton.addEventListener('click', async () => {
await form.validate()
const state = form.getState()
if (Object.keys(state.errors).length > 0) return
const payload = form.getPayload()
// payload only contains fields with live or persisted connections
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(payload),
})
})useFormConnect (React Zero-Rerender Pattern)
The React adapter's useFormConnect hook returns a stable curried ref-callback factory. Call it with a path (and optional ConnectOptions) to get a React ref callback — attach that callback to any DOM element for zero-rerender form integration:
import { useFormConnect } from '@neutro/form/adapters/react'
function PhoneField({ form }: { form: ReturnType<typeof createForm> }) {
const register = useFormConnect(form)
return (
<input
ref={register('phone', {
persist: false,
format: (v) => formatPhone(String(v)),
})}
type="tel"
/>
)
}useFormConnect(form) returns a stable factory (memoised via useCallback). Calling register(path, options?) returns a React ref callback — when the element mounts, form.connect is called; when it unmounts, the disconnect cleanup runs automatically.
Automatic ARIA
connect() manages the following ARIA attributes on the element automatically:
| Attribute | Behaviour |
|---|---|
aria-invalid | Set on connect and kept reactive — 'true' when the field has an error; 'false' otherwise. Updates whenever errors change. |
aria-describedby | Set when a [data-error="${path}"] element is found in the DOM. The element is given a generated id if it lacks one (error-desc-${path-with-dashes}). Checked reactively on each error state change — the error container does not need to be present at connect time. |
aria-required | Set to 'true' once on connect when the field's rules config includes 'required'. Not reactive — field requiredness is treated as a static schema property. Only the built-in 'required' string rule is detected; conditional rules (requiredIf, requiredUnless) are not. |
When building with framework-controlled components (React controlled inputs, Vue v-model, Svelte bind:value) rather than DOM bridge connections, see getAriaProps() instead.