form.fieldController(name) is the primary headless rendering API for a single field. It returns a type-safe runtime object that carries everything your component needs - current value, error, touched/dirty/validating flags, label, placeholder, hint, options, visibility, change/blur/focus handlers, imperative actions, and a focus bridge - while the schema keeps owning the contract (type, validation, conditional rules, persistence, analytics).
Use fieldController for every field you render. You get total control over the markup without re-implementing form state or validation timing.
- Great for: custom masked inputs, bespoke select triggers with bottom sheets or popovers, design-system wrappers, composite widgets (slider + numeric input + presets), inline editors, and any flow that needs imperative focus control.
- Unlike
field.custom(), the field keeps its original builder semantics - masked fields still run mask validation, selects still expose theiroptions, phones still normalize to E.164, OTP fields still exposeotpLength, and so on. - It can be consumed anywhere in your component tree and works with surrounding layout, modals, previews, extra buttons, or custom error UI.
- Calling
fieldController()during render gives the component the current field state; no separatewatch()call is needed.
| 1 | import { field, useFormBridge } from '@runilib/react-formbridge' |
| 2 | |
| 3 | const form = useFormBridge({ |
| 4 | workspaceName: field.text('Workspace').required(), |
| 5 | launchAccessCode: field |
| 6 | .masked('OPS-9999-LL') |
| 7 | .label('Launch access code') |
| 8 | .required() |
| 9 | .validateComplete('Complete the launch access code.'), |
| 10 | }) |
| 11 | |
| 12 | const accessCode = form.fieldController('launchAccessCode') |
| 13 | |
| 14 | return ( |
| 15 | <div className="field"> |
| 16 | <label htmlFor="launch-access-code"> |
| 17 | {accessCode.label} |
| 18 | {accessCode.required ? <span aria-hidden> *</span> : null} |
| 19 | </label> |
| 20 | |
| 21 | <input |
| 22 | id="launch-access-code" |
| 23 | ref={(node) => accessCode.registerFocusable(node)} |
| 24 | value={String(accessCode.value ?? '')} |
| 25 | placeholder={accessCode.placeholder} |
| 26 | disabled={accessCode.disabled} |
| 27 | aria-invalid={Boolean(accessCode.error)} |
| 28 | aria-describedby={accessCode.error ? 'launch-access-code-error' : undefined} |
| 29 | onChange={(event) => accessCode.onChange(event.target.value)} |
| 30 | onBlur={accessCode.onBlur} |
| 31 | onFocus={accessCode.onFocus} |
| 32 | /> |
| 33 | |
| 34 | {accessCode.hint ? <p className="hint">{accessCode.hint}</p> : null} |
| 35 | {accessCode.error ? ( |
| 36 | <p id="launch-access-code-error" className="error" role="alert"> |
| 37 | {accessCode.error} |
| 38 | </p> |
| 39 | ) : null} |
| 40 | |
| 41 | <button type="button" onClick={() => accessCode.focus()}> |
| 42 | Focus custom field |
| 43 | </button> |
| 44 | </div> |
| 45 | ) |
When to reach for it
Use fieldController(name) when all of the following are true:
- The field should stay typed as one of the built-in builder types (
text,email,select,radio,masked,phone,otp,date,file,number, …) so you inherit its validation, normalization, and conditional semantics. - You need to bind the field runtime to an input, modal/sheet trigger, composite widget, or shared design-system wrapper.
- You still want the renderer to be driven by reactive state (value, error, touched, validating, visible) so conditional rules, persistence, and analytics keep working.
If you need a brand new value type that no builder covers, use field.custom() and render its controller the same way.
Surface
Complete fieldController(name) surface:
| Method | Type | Description |
|---|---|---|
name | string | Field name as declared in the schema |
value | unknown | Current field value (reactive) |
label / placeholder / hint | string | Copy strings resolved from the schema |
error | string | undefined | Current error message for this field |
touched / dirty / validating | boolean | Per-field interaction + validation flags |
disabled / required / visible | boolean | Computed conditional flags |
options? | SelectOption[] | Options (select/radio/async) when applicable |
otpLength? | number | OTP length (OTP fields only) |
allValues | Record<string, unknown> | Snapshot of every current form value |
displayValue / rawValue / maskComplete | string / string / boolean | Masked-field metadata when applicable |
digits / otpComplete | string[] / boolean | OTP metadata when applicable |
setValue | (value) => void | Imperative value write (goes through change pipeline) |
onChange | (value) => void | Change handler expected by most controlled inputs |
onBlur / onFocus | () => void | Blur / focus event handlers |
focus / blur | () => void | Imperative focus / blur (uses the focus bridge) |
validate | () => Promise<boolean> | Run validation for this field only |
setError | (message) => void | Push an error on this field |
clearError | () => void | Clear this field error |
registerFocusable | (target) => void | Register a DOM node, TextInput, or { focus?, blur? } object for the focus bridge |
Every property is type-narrowed against the schema: value is typed as the exact value type of the field (for example string for field.text, boolean for field.checkbox, SelectOption['value'] for field.select), and options / otpLength only appear on the field types that actually expose them.
Focus bridge (registerFocusable)
controller.focus() and controller.blur() do nothing by default - you have to tell FormBridge *how* to focus your custom element. That is what registerFocusable(target) is for.
- Web - pass a DOM node via a ref callback:
ref={(node) => controller.registerFocusable(node)}. FormBridge will callnode.focus()/node.blur(). - Native - pass a React Native
TextInputref the same way. FormBridge uses its imperativefocus()/blur()methods. - Custom widgets - pass any object of shape
{ focus?: () => void; blur?: () => void }. This is how you wire modals, bottom sheets, rich editors, or third-party components. CallregisterFocusable(null)on unmount to detach.
Once registered, FormBridge can drive focus from anywhere - auto-focus on mount, focus the first invalid field after a failed submit, jump to a field when a server-side error lands, or chain focus through a wizard step.
Gotchas & best practices
- Always call onBlur() after the user finishes interacting with a custom trigger (closing a modal, leaving a popover). Without it,
touchedstaysfalseandvalidateOn: 'onBlur'/'onTouched'never fires. - Respect visible - when a conditional rule hides the field, either render nothing or render it
disabled. Hidden fields still hold a value but should not be editable. - Respect disabled - the runtime sets it from the schema and from conditional rules; forwarding it keeps the form consistent with other fields.
- Prefer onChange over setValue for user-driven edits:
onChangefires the full update pipeline (change handlers, validation, analytics), whilesetValueis meant for imperative updates (presets, paste handlers, resets). - Don't read state.values[name] directly for rendering - read
controller.value. The controller subscribes the component to that field only, avoiding re-renders on unrelated updates. - Use the type-specific controller metadata documented by each builder instead of relying on private descriptor fields.
How it compares to other escape hatches
- Use
fieldController(name)for built-in field types such asselect,masked,phone, orotp. - Use field.custom() when the schema needs a value model not covered by a built-in builder.
- Keep purely visual decisions in the component or design-system adapter that consumes the controller.