react-formbridge
Browse documentation
Renderingv2.0.0

fieldController()

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 their options, phones still normalize to E.164, OTP fields still expose otpLength, 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 separate watch() call is needed.
BasicBinding.web.tsxtsx
1import { field, useFormBridge } from '@runilib/react-formbridge'
2
3const 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
12const accessCode = form.fieldController('launchAccessCode')
13
14return (
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:

MethodTypeDescription
namestringField name as declared in the schema
valueunknownCurrent field value (reactive)
label / placeholder / hintstringCopy strings resolved from the schema
errorstring | undefinedCurrent error message for this field
touched / dirty / validatingbooleanPer-field interaction + validation flags
disabled / required / visiblebooleanComputed conditional flags
options?SelectOption[]Options (select/radio/async) when applicable
otpLength?numberOTP length (OTP fields only)
allValuesRecord<string, unknown>Snapshot of every current form value
displayValue / rawValue / maskCompletestring / string / booleanMasked-field metadata when applicable
digits / otpCompletestring[] / booleanOTP metadata when applicable
setValue(value) => voidImperative value write (goes through change pipeline)
onChange(value) => voidChange handler expected by most controlled inputs
onBlur / onFocus() => voidBlur / focus event handlers
focus / blur() => voidImperative focus / blur (uses the focus bridge)
validate() => Promise<boolean>Run validation for this field only
setError(message) => voidPush an error on this field
clearError() => voidClear this field error
registerFocusable(target) => voidRegister 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 call node.focus() / node.blur().
  • Native - pass a React Native TextInput ref the same way. FormBridge uses its imperative focus() / 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. Call registerFocusable(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, touched stays false and validateOn: '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: onChange fires the full update pipeline (change handlers, validation, analytics), while setValue is 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 as select, masked, phone, or otp.
  • 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.