Escape hatch for UI that deserves a custom renderer while keeping the rest of the form runtime.
- Use it when the built-in field types are not enough
- You still keep schema typing, validation, state, submit lifecycle, and the same generated field map
- If the value model is already one of the built-in field types and you only want to replace the UI, prefer
form.fieldController(name)before reaching forfield.custom()
Custom.tsxtsx
| 1 | const schema = { |
| 2 | rating: field.custom(0) |
| 3 | .label('Rating') |
| 4 | .render(({ label, value, onChange, error }) => ( |
| 5 | <div> |
| 6 | <p>{label}</p> |
| 7 | {[1,2,3,4,5].map((n) => ( |
| 8 | <button key={n} type="button" onClick={() => onChange(n)}> |
| 9 | {value >= n ? '★' : '☆'} |
| 10 | </button> |
| 11 | ))} |
| 12 | {error ? <p>{error}</p> : null} |
| 13 | </div> |
| 14 | )) |
| 15 | .validate((value) => (value > 0 ? null : 'Pick a rating')), |
| 16 | } |
| 17 | |
| 18 | const { Form, fields } = useFormBridge(schema) |
| 19 | |
| 20 | <Form onSubmit={save}> |
| 21 | <fields.rating /> |
| 22 | <Form.Submit>Send</Form.Submit> |
| 23 | </Form> |
Defaults, inheritance & field methods
field.custom(defaultValue) returns a typed BaseFieldBuilder - no extra methods, just the shared base surface (see Builder basics).
| Key | Description |
|---|---|
defaultValue | Required - stays typed through the generated field |
label | Optional at construction, usually added with label('...') |
render(fn) | Main escape hatch - keeps the form runtime while replacing the UI |
fieldController | If you need custom UI for a built-in field type (select, masked, phone), prefer form.fieldController(name) instead |
render(fn) receives: name, label, value, placeholder, error, touched, dirty, validating, disabled, hint, options, otpLength, onChange, onBlur, onFocus, allValues
- Shared methods: see Base field builder
Custom-field methods:
| Method | Type | Description |
|---|---|---|
- | - | field.custom(defaultValue) does not add methods on top of the base builder; it is the raw BaseFieldBuilder escape hatch. |
Recipes
Patterns that showcase custom-specific strengths.
Star rating widget
StarRating.tsxtsx
| 1 | const schema = { |
| 2 | rating: field.custom(0) |
| 3 | .label('Rating') |
| 4 | .render(({ value, onChange }) => ( |
| 5 | <Stars value={value} onChange={onChange} /> |
| 6 | )) |
| 7 | .validate((value) => (value > 0 ? null : 'Pick a rating')), |
| 8 | } |
Trip window picker
TripWindow.tsxtsx
| 1 | const schema = { |
| 2 | travelWindow: field |
| 3 | .custom<{ start: Date | null; end: Date | null }>({ |
| 4 | start: null, |
| 5 | end: null, |
| 6 | }) |
| 7 | .label('Date range') |
| 8 | .render((props) => <DateRangePicker {...props} />), |
| 9 | } |
Color picker
ColorPicker.tsxtsx
| 1 | const schema = { |
| 2 | brandColor: field.custom('#000000') |
| 3 | .label('Brand color') |
| 4 | .render(({ value, onChange }) => ( |
| 5 | <ColorWheel value={value} onChange={onChange} /> |
| 6 | )), |
| 7 | } |
When to use fieldController instead
If the value model is already a built-in type (select, masked, phone, …) and you only need a different UI, keep the schema field as-is and drive the UI through form.fieldController(name):
FieldControllerAlternative.tsxtsx
| 1 | const schema = { |
| 2 | plan: field.select('Plan').options(PLAN_OPTIONS).required(), |
| 3 | } |
| 4 | |
| 5 | const form = useFormBridge(schema) |
| 6 | const plan = form.fieldController('plan') |
| 7 | |
| 8 | <MyCustomSegmentedControl |
| 9 | value={plan.value} |
| 10 | options={plan.options} |
| 11 | onChange={plan.setValue} |
| 12 | /> |