React / <FormEngineRenderer />

FormEngineRenderer

The main component. It creates the engine, renders fields, handles navigation, validation, drafts and submission.

Basic usage

import { FormEngineRenderer } from '@squaredr/fieldcraft-react'import schema from './contact-form.json'export default function ContactPage() {  return (    <FormEngineRenderer      schema={schema}      onSubmit={async (response) => {        await fetch('/api/submit', {          method: 'POST',          body: JSON.stringify(response),        })      }}    />  )}

Props

Required

PropTypeDescription
schemaFormEngineSchemaThe form schema. Validated at mount time — invalid schemas throw FormEngineSchemaError.

Submission

PropTypeDescription
onSubmit(response: FormResponse) => void | Promise<void>Called when the form is submitted successfully. Receives the full FormResponse with values, metadata, scores.
adaptersSubmitAdapter | SubmitAdapter[]One or more submission adapters (HTTP, Supabase, Postgres, webhook). Run in parallel on submit.
onSubmitError(detail: SubmitErrorDetail) => void | falseCalled when a submission fails for a reason the respondent cannot fix — a delivery failure, or a beforeSubmit hook that returned false. Validation failures go to onValidationError instead. Return false to suppress the built-in message and show your own. Since 1.12.0
submitErrorLabelstringOverrides the built-in delivery-failure message. Provided for translation — it must stay safe to show a respondent, so never pass raw adapter text. Since 1.12.0

Theming

PropTypeDescription
themeFormEngineThemeTheme object or preset. Controls colours, typography, spacing, and shape.
classNamestringCSS class added to the root form element.

Field registry

PropTypeDescription
componentsFieldRegistryCustom field component map. Merged with the default registry — your components override built-in ones for matching types.

Data

PropTypeDescription
prefillRecord<string, unknown>Initial values to prefill into the form. Keys are field IDs.
initialValuesRecord<string, unknown>Same as prefill — alternative prop name.

Validators

PropTypeDescription
validatorsRecord<string, CustomValidator>Custom sync validators keyed by name. Referenced in schema via { type: 'custom', name: '...' }.
asyncValidatorsRecord<string, AsyncValidator>Custom async validators keyed by name. Referenced in schema via { type: 'async', endpoint: '...' }.

Drafts

PropTypeDescription
sessionTokenstringCustom session token. If not provided, a UUID is generated. Used to scope drafts per user/session.
draftAdapterDraftAdapterServer-side draft adapter (Supabase, Postgres, or custom). Enables cross-device draft persistence.
autoSaveIntervalMsnumberAuto-save interval in milliseconds. When set, the engine saves drafts automatically at this interval.
draftMigrationsRecord<string, (draft: DraftSnapshot) => DraftSnapshot>Migration functions keyed by schema version. Applied when loading a draft saved against an older schema version.

Labels

PropTypeDescription
prevLabelstringLabel for the "Back" button. Default: "Back".
nextLabelstringLabel for the "Next" button. Default: "Next".
submitLabelstringLabel for the "Submit" button. Default: "Submit".

Callbacks

PropTypeDescription
onSectionChange(sectionId: string, index: number) => voidCalled when the active section changes.
onFieldChange(fieldId: string, value: unknown) => voidCalled when any field value changes.
onReady(engine: FormEngine) => voidCalled after the engine is initialised. Receives the engine instance — use it to call engine.loadDraft(), engine.submit(), or read state programmatically.
onValidationError(errors: Record<string, string[]>) => voidCalled when validation fails (on section change or submit).
onStateChange(state: FormState) => voidCalled on every state change. Use sparingly — this fires frequently.
onEvent(event: FieldCraftEvent) => voidCalled on every engine event (field interaction, section change, submit, etc.). Useful for custom logging.
beforeSubmit(response: FormResponse) => FormResponse | false | Promise<FormResponse | false>Intercept submissions before they reach adapters. Return a modified response, or false to cancel the submission.

onValidationError and onSubmitError are deliberately separate. One means the respondent can fix this; the other means they cannot. Before 1.12.0 every failure went to onValidationError, so a network error arrived as an empty error object and the respondent saw nothing at all.

Analytics

PropTypeDescription
analyticsAnalyticsAdapterAnalytics adapter for tracking form views, starts, field interactions, submissions, and abandonment.
metadataRecord<string, unknown>Arbitrary metadata attached to form responses and analytics events. Useful for tracking source, campaign, or user context.

Behaviour

PropTypeDescription
autoFocusbooleanWhen true, the first field in each section receives focus automatically on mount and section change.
autoAdvancebooleanConversational mode only. When true, the form advances to the next question automatically after the user answers.
hideNavigationbooleanWhen true, built-in navigation buttons (Back, Next, Submit) are hidden across all display modes. Use onReady to get the engine instance, then call engine.nextSection(), engine.prevSection(), engine.submit() from your own UI. Pair with onStateChange to read canGoNext, canGoPrev, isSubmitting, and other state.

Full example

import {  FormEngineRenderer,  defaultRegistry,} from '@squaredr/fieldcraft-react'import { createSupabaseAdapter } from '@squaredr/fieldcraft-adapters'import { PainScaleField } from './custom-fields/PainScaleField'import { validators } from '@/lib/validators'import schema from './patient-intake.json'import { supabase } from '@/lib/supabase'import type { FormEngine } from '@squaredr/fieldcraft-core'const adapter = createSupabaseAdapter({  client: supabase,  table: 'intake_submissions',})export default function IntakePage() {  return (    <FormEngineRenderer      schema={schema}      adapters={adapter}      components={{ ...defaultRegistry, pain_scale: PainScaleField }}      validators={validators}      prefill={{ referral_source: 'website' }}      onSubmit={async (response) => {        console.log('Submitted:', response.schemaId, response.values)      }}      onReady={(engine: FormEngine) => {        console.log('Form ready:', engine.getSchema().title)      }}      onSectionChange={(id, idx) => {        console.log(`Section ${idx + 1}: ${id}`)      }}      submitLabel="Submit Intake Form"      autoFocus    />  )}

Headless / custom navigation

Use hideNavigation with onReady and onStateChange to replace the built-in buttons with your own UI:

Your own buttons live outside the renderer's <form>, so give them type="button" and drive them from the engine. Calling engine.nextSection() from a button that also submits a surrounding form would advance twice — nextSection() is not idempotent.

If you reuse the exported NavigationButtons component standalone, leave its insideForm prop unset: it then renders plain buttons driven by onNext and onSubmit. The renderer passes insideForm so that, inside its form, Enter and a click take one path instead of both firing.

import { useState, useCallback } from 'react'import { FormEngineRenderer } from '@squaredr/fieldcraft-react'import type { FormEngine, FormState } from '@squaredr/fieldcraft-core'export default function CustomNavForm({ schema }) {  const [engine, setEngine] = useState<FormEngine | null>(null)  const [formState, setFormState] = useState<FormState | null>(null)  return (    <div>      <FormEngineRenderer        schema={schema}        hideNavigation        onReady={setEngine}        onStateChange={setFormState}        onSubmit={(response) => console.log(response)}      />      {engine && formState && (        <div className="flex gap-2 mt-4">          <button            onClick={() => engine.prevSection()}            disabled={!formState.canGoPrev}          >            Back          </button>          {formState.currentSectionIndex < formState.totalVisibleSections - 1 ? (            <button              onClick={() => engine.nextSection()}              disabled={!formState.canGoNext}            >              Continue            </button>          ) : (            <button              onClick={() => engine.submit()}              disabled={formState.isSubmitting}            >              {formState.isSubmitting ? 'Sending…' : 'Send'}            </button>          )}        </div>      )}    </div>  )}

Handling submission failures

A submission can fail three ways, and they are not the same problem.

CauseWhere it goesCan the respondent fix it?
A field is invalidonValidationErrorYes
The adapter failed — network, 5xx, timeoutonSubmitError, kind: "delivery"No
Your beforeSubmit returned falseonSubmitError, kind: "cancelled"Not a failure — you stopped it

onSubmitError receives a SubmitErrorDetail:

type SubmitErrorDetail = {  kind: 'delivery' | 'cancelled';  message: string;                              // joined adapter messages  adapterResults: { adapterName: string; success: boolean; error?: string }[];  response?: FormResponse;                      // see note below};

The three behaviours

// 1. Pass nothing. The respondent sees the built-in message.<FormEngineRenderer schema={schema} adapters={adapters} />// 2. Log it and keep the built-in message. Return nothing.<FormEngineRenderer  schema={schema}  adapters={adapters}  onSubmitError={(detail) => {    Sentry.captureMessage(detail.message, { extra: detail.adapterResults });  }}/>// 3. Take over completely. Return false.<FormEngineRenderer  schema={schema}  adapters={adapters}  onSubmitError={(detail) => {    if (detail.kind === 'cancelled') return false;   // you stopped it; say nothing    toast.error('Could not save. Retrying shortly.');    return false;                                     // suppress the built-in notice  }}/>

Returning nothing gives you the detail and the default message, which is the common case — you usually want to know, and the respondent still needs telling.

Why the built-in message is generic

It says "We couldn't send your response. Please try again." and never shows detail.message. Raw adapter text can contain hostnames, ports and driver errors — information a respondent cannot act on and should not see. The detail goes to you; the reassurance goes to them.

Override the wording with submitErrorLabel for translation, but keep it safe to display.

detail.response is currently always undefined. SubmitResult does not carry the response, and the renderer cannot reconstruct it faithfully — the engine builds it with a session token, a completion time and merged metadata the component does not hold. Handle undefined rather than assuming a retry payload is available.

How it works internally

FormEngineRenderer is a wrapper that:

  1. Creates a FormEngine instance via useFormEngine(schema, options)
  2. Subscribes to state changes via useSyncExternalStore
  3. Selects the rendering strategy based on schema.settings.displayMode:
    • stepped (default) — one section at a time with Back/Next/Submit buttons and a progress bar
    • classic — all visible sections rendered at once with a Submit button at the bottom
    • conversational — one question at a time with Enter key support and question-level progress
  4. Handles navigation, validation, and submission
  5. Manages draft persistence if settings.allowDraftSave is true

See Display modes for details on each mode.

The engine lives in a useRef and is created once. React Strict Mode double-mounts don't create multiple engines.

Using the engine directly

If FormEngineRenderer doesn't fit your layout, use useFormEngine to get the engine and build your own UI:

import { useFormEngine } from '@squaredr/fieldcraft-react'function CustomForm({ schema }) {  const engine = useFormEngine(schema, {    onSubmit: async (response) => { /* ... */ },  })  return (    <div>      <h1>{engine.getSchema().title}</h1>      <p>Progress: {engine.state.progressPercent}%</p>      {/* Render fields manually */}    </div>  )}

See Hooks for the full hook API.

Next steps

  • Hooks — useFormEngine, useFieldValue, useFieldError, useSectionProgress
  • Theming — customise the visual appearance
  • Custom field types — register your own field components