react-formbridge
Browse documentation
Hooksv1.0.2

useDynamicFormBridge()

Turn a JSON form definition into a real formbridge runtime.

  • Useful for CMS-driven forms, experiments, back-office builders, or remote configuration
  • The hook parses the definition, preserves field order, and gives you a normal formbridge instance back
  • This helper is most compelling when the form shape changes outside the deployed frontend code
  • A very practical pattern is “one dynamic step per route” for cross-page wizards whose step definitions come from a backend
import { useMemo, useState } from 'react'
import { useDynamicFormBridge } from '@runilib/react-formbridge'

const DEFINITIONS = {
  feedback: {
    id: 'feedback',
    title: 'Product feedback',
    submitLabel: 'Send feedback',
    fields: [
      { type: 'text', name: 'fullName', label: 'Full name', required: true },
      { type: 'email', name: 'email', label: 'Email', required: true },
      {
        type: 'select',
        name: 'topic',
        label: 'Topic',
        options: ['UX', 'Performance', 'Bug report'],
        required: true,
      },
      { type: 'textarea', name: 'comment', label: 'Comment', min: 10, max: 400 },
    ],
  },
  callback: {
    id: 'callback',
    title: 'Request a callback',
    submitLabel: 'Book callback',
    fields: [
      { type: 'text', name: 'fullName', label: 'Full name', required: true },
      { type: 'tel', name: 'phone', label: 'Phone', required: true },
      {
        type: 'select',
        name: 'bestTime',
        label: 'Best time',
        options: ['Morning', 'Afternoon', 'Evening'],
        required: true,
      },
      { type: 'checkbox', name: 'hasOrder', label: 'I already have an order' },
      {
        type: 'text',
        name: 'orderNumber',
        label: 'Order number',
        showWhen: { field: 'hasOrder', value: true },
      },
    ],
  },
}

export function DynamicFormPlayground() {
  const [kind, setKind] = useState<'feedback' | 'callback'>('feedback')
  const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null)
  const definition = useMemo(() => DEFINITIONS[kind], [kind])

  const { form, fieldOrder, meta, isVisible, isLoading, loadError } = useDynamicFormBridge(
    definition,
    {
      formKey: kind,
      validateOn: 'onBlur',
      defaultValues:
        kind === 'feedback'
          ? { fullName: 'Ava Stone', topic: 'UX' }
          : { fullName: 'Ava Stone', bestTime: 'Morning' },
    },
  )

  if (!form) {
    return (
      <p>{isLoading ? 'Loading…' : 'Error: ' + String(loadError ?? 'Unknown error')}</p>
    )
  }

  const { Form, fields, state } = form

  return (
    <div style={{ fontFamily: 'sans-serif', padding: 20, background: '#f5f7fb' }}>
      <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
        <button type="button" onClick={() => setKind('feedback')}>
          Feedback form
        </button>
        <button type="button" onClick={() => setKind('callback')}>
          Callback form
        </button>
      </div>

      <p style={{ marginTop: 0, color: '#4b5563' }}>
        Current definition: <strong>{meta.title ?? 'Untitled form'}</strong>
      </p>

      <Form
        onSubmit={async (values) => {
          setSubmitted(values)
        }}
      >
        {fieldOrder.filter((name) => isVisible(name)).map((name) => {
          const Field = fields[name]
          return <Field key={name} />
        })}

        <Form.Submit>{meta.submitLabel ?? 'Submit'}</Form.Submit>
      </Form>

      <div style={{ display: 'grid', gap: 12, marginTop: 16 }}>
        <div
          style={{
            border: '1px solid #d6d9e0',
            borderRadius: 12,
            padding: 12,
            background: '#fff',
          }}
        >
          <strong>Live values</strong>
          <pre style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
            {JSON.stringify(state.values, null, 2)}
          </pre>
        </div>

        <div
          style={{
            border: '1px solid #d6d9e0',
            borderRadius: 12,
            padding: 12,
            background: '#fff',
          }}
        >
          <strong>Last submit</strong>
          <pre style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
            {JSON.stringify(submitted, null, 2)}
          </pre>
        </div>
      </div>
    </div>
  )
}

export default DynamicFormPlayground

Options

Complete useDynamicFormBridge() options surface:

MethodTypeDescription
1st argumentJsonFormDefinition | () => Promise<JsonFormDefinition>Static definition or async loader
2nd argumentUseFormBridgeOptionsAll normal useFormBridge() options
defaultValues?Record<string, unknown>Injected after parsing the dynamic schema

Supported JSON form definition surface:

MethodTypeDescription
JsonFormDefinition{ id?, title?, submitLabel?, fields }Root JSON structure consumed by the parser
JsonFieldTypetext | email | password | number | tel | url | textarea | checkbox | switch | select | radio | date | otp | hiddenAccepted field types
JsonFieldDescriptor{ name, type, label, placeholder?, hint?, defaultValue?, required?, min?, max?, pattern?, patternMsg?, options?, otpLength?, disabled?, order?, showWhen?, validate? }Per-field descriptor shape
showWhen{ field, value } | { field, notValue }Visibility rule
validate entriesrequired | min | max | pattern | email | urlTyped rule list
custom-Exists in the JSON rule surface today, but the built-in parser does not execute arbitrary custom JSON validators yet

Because the returned form is a standard bridge instance, submit/error handlers still live on <Form>.

Cross-page / cross-screen wizard pattern

Use useDynamicFormBridge() for cross-page or cross-screen wizards when the backend already decides the fields for each step and the router or navigator already decides which page is active.

  • Put the route param in the dynamic loader so each page fetches only its own step definition
  • On native, treat the screen param the same way and fetch one step definition per screen
  • Set formKey: stepId so the form runtime resets cleanly when the route changes
  • Include the step id in persist.key to avoid draft collisions between pages
  • Submit the current step to your API, then let the router navigate to the next page
  • This pattern is ideal when the server owns the canonical onboarding session or partial payload

Return

Complete useDynamicFormBridge() return surface:

MethodTypeDescription
formUseFormBridgeReturn | nullStandard useFormBridge() return, or null while unavailable
fieldOrderstring[]Parser-declared render order
meta{ id?, title?, submitLabel? }Metadata extracted from the JSON definition
isVisible(name) => booleanEvaluates dynamic showWhen rules against live values
isLoadingbooleantrue while the async loader resolves
loadErrorstring | nullError message if the loader rejected

Platform note

Dynamic forms are easiest to adopt in web dashboards first. If you target native too, validate the exact field set and renderer combination you plan to ship, because dynamic helpers tend to surface edge cases later than static schemas.

  • useDynamicFormBridge() is excellent for one dynamic step per route
  • If you also want a client-owned wizard state machine with progress, completedSteps, allValues, goToStep(), and route-driven restoration, prefer useFormBridgeWizard({ stepId, onStepChange }) once the step schemas are known in the client