react-formbridge
Browse documentation
Componentsv1.0.2

fieldController()

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 their options, phones still normalize to E.164, OTP fields still expose otpLength, 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 manual watch() 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.
  • 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:

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
descriptorFieldDescriptorRaw descriptor produced by the builder
renderPropsRenderContextContext object passed to custom render(fn) hooks
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 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 a field.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 as select, masked, phone, or otp.
  • 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 (on field.select / field.radio / field.date) over fieldController(name) when only the picker surface changes and the built-in field trigger is already good enough.
  • Prefer globalDefaults or per-field props over fieldController when the change is purely visual - styling, spacing, label alignment - and the underlying markup is fine.