react-formbridge
Browse documentation
Interactives Tutorialsv1.0.2

Tutorial: signup form

Build a real account-creation form with typed fields, conditional company fields, inline validation, and the same schema on web and native.

  • One schema defines labels, rules, defaults, and platform hints
  • useFormBridge() gives you the wrapper, generated fields, submit lifecycle, and reactive state
  • Conditional company fields stay in the builder instead of leaking into component branches
import type { FormSchema } from '@runilib/react-formbridge'
import { field, useFormBridge } from '@runilib/react-formbridge'

const schema = {
  firstName: field.text('First name').required().trim(),
  lastName: field.text('Last name').required().trim(),
  email: field.email('Work email').required().trim().lowercase(),
  password: field.password('Password').required().strong(),
  accountType: field.select('Account type').options([
    { label: 'Personal', value: 'personal' },
    { label: 'Company', value: 'company' },
  ]).required(),
  companyName: field
    .text('Company name')
    .visibleWhen('accountType', 'company')
    .requiredWhen('accountType', 'company')
    .clearOnHide(),
  terms: field.checkbox('Accept terms').mustBeTrue(),
} satisfies FormSchema

export function SignupForm() {
  const { Form, fields, state } = useFormBridge(schema, {
    validateOn: 'onTouched',
    revalidateOn: 'onChange',
  })

  return (
    <Form onSubmit={async (values) => api.signup(values)}>
      <fields.firstName />
      <fields.lastName />
      <fields.email />
      <fields.password />
      <fields.accountType />
      <fields.companyName />
      <fields.terms />
      <Form.Submit disabled={!state.isValid}>Create account</Form.Submit>
    </Form>
  )
}

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

export default SignupForm

Track state and submit lifecycle

You rarely need custom local state for the form itself. The hook already exposes the pieces most product flows care about.

  • state.isValid, state.isDirty, and state.isSubmitting drive button states and shell feedback
  • watchAll() is useful for live previews or summary cards
  • submit() lets you trigger the same submit pipeline from an outer button or wizard shell
SignupState.tsxtsx
1const form = useFormBridge(schema, {
2 validateOn: 'onTouched',
3 revalidateOn: 'onChange',
4})
5
6const { Form, fields, state, watchAll, submit } = form
7const liveValues = watchAll()
8const canContinue = state.isValid && !state.isSubmitting
9
10return (
11 <>
12 <aside>{liveValues.email || 'No email yet'}</aside>
13 <Form onSubmit={async (values) => api.signup(values)}>
14 <fields.email />
15 <fields.password />
16 <Form.Submit disabled={!canContinue}>Create account</Form.Submit>
17 </Form>
18 <button type="button" onClick={() => void submit()}>
19 Submit from outer shell
20 </button>
21 </>
22)

Progressive disclosure stays in the schema

Conditional fields are one of the first places where ad hoc forms become noisy. Keep them in the builders instead.

  • visibleWhen(...) controls whether the field renders
  • requiredWhen(...) keeps the validation rule aligned with visibility
  • clearOnHide() or resetOnHide() prevents stale hidden values from leaking into submit payloads
SignupDisclosure.tsts
1const schema = {
2 accountType: field.select('Account type').options([
3 { label: 'Personal', value: 'personal' },
4 { label: 'Company', value: 'company' },
5 ]).required(),
6 companyName: field
7 .text('Company name')
8 .visibleWhen('accountType', 'company')
9 .requiredWhen('accountType', 'company')
10 .clearOnHide(),
11 vatNumber: field
12 .text('VAT number')
13 .visibleWhen('accountType', 'company')
14 .requiredWhen('accountType', 'company')
15 .clearOnHide(),
16}