react-formbridge
Browse documentation
Hooksv2.0.0

useFormBridgeReadonly()

Render schema-driven values as readonly rows or as a diff against original values.

  • Useful for review steps before submission, audit views, change approval screens, or before/after comparisons
  • It reuses the schema labels and option metadata, so your review UI stays aligned with your editing UI
import { useState } from 'react'
import {
  field,
  useFormBridge,
  useFormBridgeReadonly,
} from '@runilib/react-formbridge'

const schema = {
  fullName: field.text('Full name').required(),
  email: field.email('Email').required(),
  country: field
    .select('Country')
    .options([
      { label: 'France', value: 'FR' },
      { label: 'United States', value: 'US' },
      { label: 'United Kingdom', value: 'GB' },
    ])
    .required(),
  newsletter: field.checkbox('Receive product updates'),
}

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

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

const rowStyle = {
  border: '1px solid #d6d9e0',
  borderRadius: 12,
  padding: 12,
  background: '#fff',
}

export function ReadonlyPlayground() {
  const [mode, setMode] = useState<'readonly' | 'diff'>('diff')
  const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null)

  const form = useFormBridge(schema, {
    validateOn: 'onBlur',
    initialValues: originalValues,
  })

  const { Form, fieldController, state } = form

  const readonly = useFormBridgeReadonly(schema, {
    mode,
    values: state.values,
    originalValues,
  })

  const { changedFields, fieldNames, fields: previewFields, hasChanges } = readonly

  return (
    <div
      style={{
        fontFamily: 'sans-serif',
        padding: 20,
        background: '#f5f7fb',
        display: 'grid',
        gap: 16,
      }}
    >
      <div>
        <h3 style={{ margin: '0 0 8px' }}>Edit profile + readonly preview</h3>
        <p style={{ margin: 0, color: '#4b5563' }}>
          The form stays editable, while the preview reuses the same schema labels,
          select labels, and diff metadata.
        </p>
      </div>

      <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
        <button type="button" onClick={() => form.resetFields(editedValues)}>
          Load sample edits
        </button>
        <button type="button" onClick={() => form.resetFields(originalValues)}>
          Reset to original
        </button>
        <button
          type="button"
          onClick={() =>
            setMode((current) => (current === 'diff' ? 'readonly' : 'diff'))
          }
        >
          Toggle {mode === 'diff' ? 'readonly' : 'diff'} preview
        </button>
      </div>

      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
          gap: 16,
          alignItems: 'start',
        }}
      >
        <Form
          onSubmit={async (values) => {
            setSubmitted(values)
          }}
        >
          <div style={{ display: 'grid', gap: 12 }}>
            <h4 style={{ margin: 0 }}>Editable form</h4>
            <AppField form={form} name="fullName" />
            <AppField form={form} name="email" />
            <AppField form={form} name="country" />
            <AppField form={form} name="newsletter" />
            <button type="submit">Save profile</button>
          </div>
        </Form>

        <aside style={{ display: 'grid', gap: 10 }}>
          <div>
            <h4 style={{ margin: '0 0 4px' }}>Readonly preview</h4>
            <p style={{ margin: 0, color: '#4b5563', fontSize: 13 }}>
              Mode: <strong>{mode}</strong>
              {' · '}
              {hasChanges
                ? `${changedFields.length} changed field(s)`
                : 'No detected changes'}
            </p>
          </div>

          {fieldNames.map((name) => {
            const preview = previewFields[name]

            return (
              <div
                key={preview.name}
                style={{
                  ...rowStyle,
                  borderColor:
                    mode === 'diff' && preview.changed ? '#38bdf8' : '#d6d9e0',
                }}
              >
                <div
                  style={{
                    display: 'flex',
                    justifyContent: 'space-between',
                    gap: 8,
                  }}
                >
                  <strong>{preview.label}</strong>
                  {mode === 'diff' && preview.changed ? (
                    <span style={{ color: '#0369a1', fontSize: 12 }}>Edited</span>
                  ) : null}
                </div>

                <p style={{ margin: '8px 0 0' }}>{preview.display}</p>

                {mode === 'diff' && preview.changed ? (
                  <p
                    style={{
                      margin: '6px 0 0',
                      color: '#64748b',
                      fontSize: 13,
                    }}
                  >
                    Original: {preview.originalDisplay}
                  </p>
                ) : null}
              </div>
            )
          })}
        </aside>
      </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>
  )
}

function AppField({ form, name }) {
  const controller = form.fieldController(name)

  if (!controller.visible) return null

  if (controller.options?.length) {
    return (
      <label>
        {controller.label}
        <select
          value={String(controller.value ?? '')}
          disabled={controller.disabled}
          onChange={(event) => controller.onChange(event.target.value)}
          onBlur={controller.onBlur}
        >
          {controller.options.map((option) => (
            <option key={String(option.value)} value={option.value}>
              {option.label}
            </option>
          ))}
        </select>
        {controller.error ? <span role="alert">{controller.error}</span> : null}
      </label>
    )
  }

  if (typeof controller.value === 'boolean') {
    return (
      <label>
        <input
          type="checkbox"
          checked={controller.value}
          disabled={controller.disabled}
          onChange={(event) => controller.onChange(event.target.checked)}
          onBlur={controller.onBlur}
        />
        {controller.label}
      </label>
    )
  }

  return (
    <label>
      {controller.label}
      <input
        value={controller.displayValue ?? String(controller.value ?? '')}
        placeholder={controller.placeholder}
        disabled={controller.disabled}
        onChange={(event) => controller.onChange(event.target.value)}
        onBlur={controller.onBlur}
      />
      {controller.error ? <span role="alert">{controller.error}</span> : null}
    </label>
  )
}

export default ReadonlyPlayground

Options

  • First argument: the schema

Complete useFormBridgeReadonly() options surface:

MethodTypeDescription
mode'readonly' | 'diff''readonly' renders plain read-only rows. 'diff' highlights fields whose values[name] differs from originalValues[name] and exposes the before/after pair
valuesSchemaValues<S>Current values to render - shape comes directly from your schema, so every key is typed
originalValues?Partial<SchemaValues<S>>Baseline values used in diff mode to compute changed / changedFields. Ignored in readonly mode. Fields absent from this map are never flagged as changed
formatters?Partial<Record<keyof S, (value) => string>>Per-field display formatter. Overrides the built-in formatting (dates → toLocaleDateString(), booleans → ✓ Yes / ✗ No, passwords → ••••••••, select/radio → matching option label, empty → -)

Return

Each fields[name] entry (FieldReadonlyState) contains:

MethodTypeDescription
namestringThe schema key
labelstringSame label as in the editing form (descriptor._label ?? "")
valueunknownRaw value from options.values
displaystringFormatted, ready-to-render string (custom formatter wins over the built-in one)
changedbooleantrue only in diff mode when the field has an entry in originalValues and it differs from value (via Object.is)
original?unknownRaw originalValues[name] - present only when changed is true
originalDisplay?stringFormatted version of original - useful for rendering a "before" column. Present only when changed is true

Complete useFormBridgeReadonly() return surface:

MethodTypeDescription
fieldsRecord<keyof S, FieldReadonlyState>Computed readonly state for every visible (non-_hidden) field - see the field state table above
fieldNamesArray<keyof S>Visible field names in schema iteration order - use this to render rows deterministically instead of Object.keys(fields)
changedFieldsArray<keyof S>Subset of fieldNames whose changed flag is true. Always empty in readonly mode
hasChangesbooleanShorthand for changedFields.length > 0. Handy for "Nothing changed" empty states

Rendering note

The hook returns formatted state, not components. Iterate over fieldNames and render fields[name] with your own web or native review-row component.