form.fieldController(name) is FormBridge's headless escape hatch for a single field. It returns a fully reactive, type-safe runtime object that carries everything a renderer 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).
Reach for fieldController when the generated fields.* component is not flexible enough, but you still want the field to behave like a first-class schema field. You get total control over the markup without re-implementing form state, validation timing, or error display.
- 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. - Unlike builder-level
.render(fn), the controller can be consumed anywhere in your component tree and plays nicely with surrounding layout, modals, previews, extra buttons, or custom error UI. - It is fully reactive: reading
controller.value,controller.error,controller.visible, etc. subscribes the calling component to that field - no manualwatch()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. - The default generated component doesn't fit - you need custom chrome, imperative focus, a modal/sheet trigger, extra buttons, a composite widget, or a 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 only need to restyle the default renderer, prefer globalDefaults or per-field props first. If you need a brand new field type that no builder covers, jump to field.custom() instead.
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 |
descriptor | FieldDescriptor | Raw descriptor produced by the builder |
renderProps | RenderContext | Context object passed to custom render(fn) hooks |
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 controller.descriptor (advanced) when you need to inspect the resolved builder descriptor - for example to read the mask pattern, the min/max of a
field.number(), or the accepted MIME types of afield.file()- without re-declaring them in the renderer.
How it compares to other escape hatches
- Prefer
fieldController(name)over field.custom() when you still want a built-in field type such asselect,masked,phone, orotp. - Prefer
fieldController(name)over builder-level.render(fn)when the custom UI needs surrounding layout, external buttons, modal state, or imperative focus control from outside the field. - Prefer
renderPicker(onfield.select/field.radio/field.date) overfieldController(name)when only the picker surface changes and the built-in field trigger is already good enough. - Prefer globalDefaults or per-field props over
fieldControllerwhen the change is purely visual - styling, spacing, label alignment - and the underlying markup is fine.