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
| Prop | Type | Description |
|---|---|---|
schema | FormEngineSchema | The form schema. Validated at mount time — invalid schemas throw FormEngineSchemaError. |
Submission
| Prop | Type | Description |
|---|---|---|
onSubmit | (response: FormResponse) => void | Promise<void> | Called when the form is submitted successfully. Receives the full FormResponse with values, metadata, scores. |
adapters | SubmitAdapter | SubmitAdapter[] | One or more submission adapters (HTTP, Supabase, Postgres, webhook). Run in parallel on submit. |
onSubmitError | (detail: SubmitErrorDetail) => void | false | Called 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 |
submitErrorLabel | string | Overrides 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
| Prop | Type | Description |
|---|---|---|
theme | FormEngineTheme | Theme object or preset. Controls colours, typography, spacing, and shape. |
className | string | CSS class added to the root form element. |
Field registry
| Prop | Type | Description |
|---|---|---|
components | FieldRegistry | Custom field component map. Merged with the default registry — your components override built-in ones for matching types. |
Data
| Prop | Type | Description |
|---|---|---|
prefill | Record<string, unknown> | Initial values to prefill into the form. Keys are field IDs. |
initialValues | Record<string, unknown> | Same as prefill — alternative prop name. |
Validators
| Prop | Type | Description |
|---|---|---|
validators | Record<string, CustomValidator> | Custom sync validators keyed by name. Referenced in schema via { type: 'custom', name: '...' }. |
asyncValidators | Record<string, AsyncValidator> | Custom async validators keyed by name. Referenced in schema via { type: 'async', endpoint: '...' }. |
Drafts
| Prop | Type | Description |
|---|---|---|
sessionToken | string | Custom session token. If not provided, a UUID is generated. Used to scope drafts per user/session. |
draftAdapter | DraftAdapter | Server-side draft adapter (Supabase, Postgres, or custom). Enables cross-device draft persistence. |
autoSaveIntervalMs | number | Auto-save interval in milliseconds. When set, the engine saves drafts automatically at this interval. |
draftMigrations | Record<string, (draft: DraftSnapshot) => DraftSnapshot> | Migration functions keyed by schema version. Applied when loading a draft saved against an older schema version. |
Labels
| Prop | Type | Description |
|---|---|---|
prevLabel | string | Label for the "Back" button. Default: "Back". |
nextLabel | string | Label for the "Next" button. Default: "Next". |
submitLabel | string | Label for the "Submit" button. Default: "Submit". |
Callbacks
| Prop | Type | Description |
|---|---|---|
onSectionChange | (sectionId: string, index: number) => void | Called when the active section changes. |
onFieldChange | (fieldId: string, value: unknown) => void | Called when any field value changes. |
onReady | (engine: FormEngine) => void | Called 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[]>) => void | Called when validation fails (on section change or submit). |
onStateChange | (state: FormState) => void | Called on every state change. Use sparingly — this fires frequently. |
onEvent | (event: FieldCraftEvent) => void | Called 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
| Prop | Type | Description |
|---|---|---|
analytics | AnalyticsAdapter | Analytics adapter for tracking form views, starts, field interactions, submissions, and abandonment. |
metadata | Record<string, unknown> | Arbitrary metadata attached to form responses and analytics events. Useful for tracking source, campaign, or user context. |
Behaviour
| Prop | Type | Description |
|---|---|---|
autoFocus | boolean | When true, the first field in each section receives focus automatically on mount and section change. |
autoAdvance | boolean | Conversational mode only. When true, the form advances to the next question automatically after the user answers. |
hideNavigation | boolean | When 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.
| Cause | Where it goes | Can the respondent fix it? |
|---|---|---|
| A field is invalid | onValidationError | Yes |
| The adapter failed — network, 5xx, timeout | onSubmitError, kind: "delivery" | No |
Your beforeSubmit returned false | onSubmitError, 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:
- Creates a
FormEngineinstance viauseFormEngine(schema, options) - Subscribes to state changes via
useSyncExternalStore - 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
- Handles navigation, validation, and submission
- Manages draft persistence if
settings.allowDraftSaveistrue
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