react-formbridge
Browse documentation
Interactives Tutorialsv1.0.2

Tutorial: advanced flows

Once simple forms work, the next problems are usually navigation, partial saves, remote step definitions, review screens, and instrumentation.

  • useFormBridgeWizard() is the client-owned path for multi-step flows
  • useDynamicFormBridge() is the backend-owned path when a remote definition controls the fields
  • useFormBridgeReadonly() keeps review screens aligned with edit screens
  • useFormBridgeAnalytics() makes lifecycle instrumentation additive instead of invasive
import { useEffect } from 'react'
import {
  MemoryRouter,
  Navigate,
  Route,
  Routes,
  useNavigate,
  useParams,
} from 'react-router-dom'
import type { FormSchema } from '@runilib/react-formbridge'
import { field, useFormBridgeWizard } from '@runilib/react-formbridge'

const steps = [
  {
    id: 'account',
    label: 'Account',
    schema: {
      email: field.email('Email').required(),
      password: field.password('Password').required(),
    } satisfies FormSchema,
  },
  {
    id: 'company',
    label: 'Company',
    schema: {
      companyName: field.text('Company name').required(),
    } satisfies FormSchema,
  },
  { id: 'review', label: 'Review', schema: {} satisfies FormSchema },
]

export function SignupWizardRoute() {
  const navigate = useNavigate()
  const { stepId } = useParams()

  const wizard = useFormBridgeWizard(steps, {
    stepId,
    initialStepId: 'account',
    persist: { key: 'signup-wizard', storage: 'local' },
    onStepChange: ({ step }) => navigate('/signup/' + step.id),
    onSubmit: (allValues) => api.save(allValues),
  })

  useEffect(() => {
    if (!wizard.isHydrating && wizard.currentStepId && stepId !== wizard.currentStepId) {
      navigate('/signup/' + wizard.currentStepId, { replace: true })
    }
  }, [navigate, stepId, wizard.currentStepId, wizard.isHydrating])

  if (wizard.isHydrating || !wizard.step) return null

  const { Form, fields } = wizard.currentStep

  return (
    <Form onSubmit={async () => {
      if (wizard.isLastStep) await wizard.submit()
      else await wizard.next()
    }}>
      {'email' in fields && <fields.email />}
      {'password' in fields && <fields.password />}
      {'companyName' in fields && <fields.companyName />}
      <Form.Submit>{wizard.isLastStep ? 'Finish' : 'Next'}</Form.Submit>
    </Form>
  )
}

export default function App() {
  return (
    <MemoryRouter initialEntries={['/signup/account']}>
      <Routes>
        <Route path="/" element={<Navigate replace to="/signup/account" />} />
        <Route path="/signup/:stepId" element={<SignupWizardRoute />} />
      </Routes>
    </MemoryRouter>
  )
}

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

Dynamic forms from a backend definition

Use useDynamicFormBridge() when a remote definition controls the field order or even the field set itself.

import { useDynamicFormBridge } from '@runilib/react-formbridge'

const definition = {
  title: 'Feedback',
  fields: [
    { type: 'text', name: 'fullName', label: 'Full name', required: true },
    { type: 'email', name: 'email', label: 'Email', required: true },
    { type: 'textarea', name: 'comment', label: 'Comment', max: 400 },
  ],
}

export function DynamicFeedback() {
  const { form, fieldOrder, isLoading, loadError } = useDynamicFormBridge(definition, {
    validateOn: 'onSubmit',
    defaultValues: { fullName: 'Ava Stone' },
  })

  if (!form) return isLoading ? <p>Loading…</p> : <p>Error: {loadError}</p>

  const { Form, fields } = form
  return (
    <Form onSubmit={(values) => api.send(values)}>
      {fieldOrder.map((name) => {
        const Field = fields[name]
        return <Field key={name} />
      })}
      <Form.Submit>Send</Form.Submit>
    </Form>
  )
}

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

export default DynamicFeedback

Readonly reviews before submit or approval

Review screens become much easier to keep in sync when they reuse the same schema labels and option metadata.

import { field, useFormBridgeReadonly } from '@runilib/react-formbridge'

const schema = {
  fullName: field.text('Full name'),
  email: field.email('Email'),
  country: field.select('Country').options(['FR', 'US', 'GB']),
}

const originalValues = {
  fullName: 'Ava Martin',
  email: 'ava@runilib.dev',
  country: 'FR',
}

const editedValues = {
  fullName: 'Ava Martin',
  email: 'ava.martin@runilib.dev',
  country: 'GB',
}

export default function ReviewCard() {
  const { ReadonlyFields, changedFields, hasChanges } = useFormBridgeReadonly(schema, {
    values: editedValues,
    originalValues,
    mode: 'diff',
  })

  return (
    <section style={{ display: 'grid', gap: 12, padding: 16, fontFamily: 'sans-serif' }}>
      <ReadonlyFields.fullName />
      <ReadonlyFields.email />
      <ReadonlyFields.country />
      {hasChanges ? (
        <p style={{ margin: 0, color: '#0369a1' }}>
          {changedFields.length} field(s) changed.
        </p>
      ) : null}
    </section>
  )
}

Add analytics without rewriting the form

Analytics hooks are most useful when they stay additive. Keep the same form API and plug in lifecycle tracking next to it.

import {
  field,
  useFormBridge,
  useFormBridgeAnalytics,
} from '@runilib/react-formbridge'

const schema = {
  email: field.email('Email').required(),
  role: field.select('Role').options(['admin', 'editor', 'viewer']).required(),
}

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

  useFormBridgeAnalytics(
    {
      formId: 'signup',
      exclude: ['password'],
      handlers: {
        onFieldFocus: (name) => analytics.track('field_focus', { name }),
        onFieldComplete: (name, ms) => analytics.track('field_complete', { name, ms }),
        onFormCompleted: (durationMs, submitCount, fieldCount) =>
          analytics.track('signup_success', { durationMs, submitCount, fieldCount }),
        onFormAbandoned: (pct, lastField, values) =>
          analytics.track('signup_abandon', { pct, lastField, values }),
      },
    },
    () => form.state.values,
  )

  return (
    <form.Form onSubmit={async (values) => api.signup(values)}>
      <form.fields.email />
      <form.fields.role />
      <form.Form.Submit>Create account</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 InstrumentedSignup