react-formbridge
Browse documentation
Interactives Tutorialsv1.0.2

Tutorial: custom UI & styling

Generated fields are the default path, but the runtime still leaves room for design-system wrappers and fully bespoke inputs.

  • Use fieldController(name) when the value model is still one of the built-in field types and you only want custom UI
  • Use field.custom(defaultValue) when the field needs a new value model
  • Keep styling in globalDefaults, local field overrides, or host components so the schema stays focused on behavior
import { field, useFormBridge } from '@runilib/react-formbridge'

const schema = {
  workspaceName: field.text('Workspace').required(),
  launchAccessCode: field
    .masked('OPS-9999-LL')
    .label('Launch access code')
    .placeholder('2048-QA')
    .hint('Rendered manually through form.fieldController(...).')
    .required()
    .validateComplete('Complete the launch access code.'),
}

export function MissionControlForm() {
  const form = useFormBridge(schema, {
    validateOn: 'onBlur',
    revalidateOn: 'onChange',
  })

  const accessCodeController = form.fieldController('launchAccessCode')

  return (
    <form.Form onSubmit={async (values) => api.save(values)}>
      <form.fields.workspaceName />
      <label htmlFor={accessCodeController.id}>{accessCodeController.label}</label>
      <input
        id={accessCodeController.id}
        ref={(node) => accessCodeController.registerFocusable(node)}
        value={String(accessCodeController.value ?? '').replace(/^OPS-/, '')}
        placeholder="2048-QA"
        disabled={accessCodeController.disabled}
        onChange={(event) => accessCodeController.onChange('OPS-' + event.target.value.toUpperCase())}
        onBlur={accessCodeController.onBlur}
        onFocus={accessCodeController.onFocus}
      />
      {accessCodeController.error ? <p>{accessCodeController.error}</p> : null}
      <button type="button" onClick={() => accessCodeController.focus()}>Focus code field</button>
      <form.Form.Submit>Save</form.Form.Submit>
    </form.Form>
  )
}

const api = new Proxy({}, {
  get: (_target, methodName) => async (values) => {
    console.log('[doc-playground]', String(methodName), values)
    return { methodName, values }
  },
})

export default MissionControlForm

Use field.custom() for new value models

If the built-in field families are close but not quite right, keep the new value model typed in the schema and replace the renderer completely.

CustomRating.tsxtsx
1const schema = {
2 rating: field
3 .custom(0)
4 .label('Rating')
5 .render(({ label, value, onChange, error }) => (
6 <div>
7 <p>{label}</p>
8 {[1, 2, 3, 4, 5].map((step) => (
9 <button key={step} type="button" onClick={() => onChange(step)}>
10 {value >= step ? '★' : '☆'}
11 </button>
12 ))}
13 {error ? <p>{error}</p> : null}
14 </div>
15 ))
16 .validate((value) => (value > 0 ? null : 'Pick a rating')),
17}

Style the same runtime in different ways

The product shell can evolve without rewriting the field semantics. Keep styling in the UI layer and keep behavior in the schema.

import styled from 'styled-components'
import {
  FieldHost,
  FormHost,
  SubmitHost,
  field,
  useFormBridge,
} from '@runilib/react-formbridge'

const Shell = styled(FormHost)`
  display: grid;
  gap: 14px;
`

const EmailField = styled(FieldHost).attrs({
  inputProps: { autoComplete: 'email', inputMode: 'email' },
})`
  & input {
    border-radius: 8px;
    border: 1px solid #cbd5e1;
  }
`

const SubmitButton = styled(SubmitHost)`
  border-radius: 8px;
  background: #2563eb;
  color: white;
`

const schema = {
  email: field.email('Email').required(),
  password: field.password('Password').required(),
}

export function StyledRecipe() {
  const form = useFormBridge(schema)

  return (
    <Shell form={form.Form} onSubmit={async (values) => api.save(values)}>
      <EmailField field={form.fields.email} />
      <FieldHost field={form.fields.password} />
      <SubmitButton submit={form.Form.Submit}>Sign in</SubmitButton>
    </Shell>
  )
}

const api = new Proxy({}, {
  get: (_target, methodName) => async (values) => {
    console.log('[doc-playground]', String(methodName), values)
    return { methodName, values }
  },
})

export default StyledRecipe