Note
form0 is in active development and is available to use today. Its schema format and core concepts are stable in practice, but releases before 1.0 may include breaking changes. Pin your versions and review the release notes when upgrading. A formally stable release is coming.
form0-react-native is the React Native UI layer for the form0 ecosystem. It
combines form0-core with platform-native field renderers for iOS and Android while allowing an
application to provide its own components, theme, media handling, and image resolution.
For a new project, install form0-cli and follow the
quickstart. Choose the mobile application
option to start from the maintained Expo template.
Install form0-react-native directly when integrating it into an existing React Native project.
npm install form0-react-nativeInstall compatible versions of the package's React Native peer dependencies. In an Expo project,
use npx expo install so Expo selects compatible native versions.
import { FormRenderer } from 'form0-react-native';
const schema = {
form: {
name: 'Inspection',
status_field: null,
elements: [
{
type: 'TextField',
key: 'site_name',
data_name: 'site_name',
label: 'Site name',
display: 'default',
description: null,
description_mode: null,
required: true,
required_conditions: null,
visible: true,
visible_conditions: null,
read_only: false,
read_only_conditions: null,
default_value: null,
pattern: null,
pattern_description: null,
supporting_image: false,
supporting_image_path: null,
supporting_image_display: null,
},
],
},
};
export function InspectionForm() {
return (
<FormRenderer
schema={schema}
engineOptions={{ events: { recordKind: 'new' } }}
onSubmit={(record) => console.log(record)}
/>
);
}FormRenderer accepts a renderers prop so applications can replace or extend field components
without forking the package:
const renderers = {
PhotoField: CustomPhotoField,
VideoField: CustomVideoField,
};
<FormRenderer
schema={schema}
engineOptions={{ events: { recordKind: 'new' } }}
renderers={renderers}
/>;Important
PhotoField and VideoField use placeholder-backed defaults. Production applications should
provide renderers that own capture, storage, upload, and permission handling for those fields.
SignatureField has a built-in native renderer and can also be overridden. The package includes a
mobile navigation and validation sheet, repeatable-section drilldown, theme overrides, and an
image resolver integration point.
Repeatable-row editors no longer dispatch load-record, edit-record, or change automatically
on their row engine. Earlier versions dispatched them when opening/editing a row or changing a row
field. Automatic main-record dispatch is preserved: the root engine still receives load-record
when ready, edit-record when entering edit mode, and change after a root field edit.
If your application relied on the previous automatic row-engine dispatch, review its handlers before
upgrading. Explicit engineOptions.events.lifecycle: false preserves that legacy behavior.
Use the managed integration described below for the portable repeatable-event contract tracked in core
#98 and
#102.
- Node.js 22 or newer
- React 18 or 19
- React Native 0.72 or newer
- Compatible
lucide-react-native,react-native-svg, and optional safe-area dependencies
See the React parity tracker for current renderer coverage and planned work.
- Quickstart
- Bindings overview
- Install and render with React Native
- FormRenderer
- Custom field renderers
- Mobile integration
- React Native API reference
- Expo mobile starter
Schema expressions are evaluated by form0-core. Only use schemas from trusted authors and review
the form0-core security policy.
Report vulnerabilities according to this repository's security policy.
See SUPPORT.md for help and CONTRIBUTING.md to contribute.
engineOptions forwards helpers, security, onWarning, onUpdate, onOperations, and calculation diagnostic controls to root and repeatable-row engines. Helpers and security are engine-construction settings: remount FormRenderer to replace them predictably. Callbacks and the console preference stay current in already-open editors without an extra evaluation.
const findingsByScope = new Map();
<FormRenderer
schema={schema}
engineOptions={{
events: { recordKind: 'existing' },
diagnostics: { console: false },
onDiagnostics(snapshot, meta) {
if (meta.kind === 'scope-ended') findingsByScope.delete(meta.scopeId);
else findingsByScope.set(meta.scopeId, { snapshot, meta });
},
}}
/>;Console reporting is enabled by default. diagnostics.console: false suppresses calculation details, recovery and scope-ended console notices; collection and callbacks remain active. Providing onDiagnostics does not implicitly disable console reporting. Core codes, severity, messages, phases, suggestions and context are preserved. Developer console headings use English, as with the existing binding messages.
onDiagnostics(snapshot, meta) receives a defensive array after each accepted completed evaluation, including the first, identical findings and [] after recovery. Console output suppresses identical repeats and reports new/changed findings, recovery and recurrence. Errors from diagnostic observers, including rejected promises, cannot interrupt evaluation or operation processing.
Metadata contains scopeId, instancePath, evaluationId and kind. A scope identifies one transient engine lifetime; evaluation IDs increase within that scope. Root paths are []. Each row path segment contains repeatableKey, instanceId, zero-based index, and identity: "id" | "position". Existing id or record_id values are used, with an explicitly positional fallback when absent. Findings carry the same path in context.instancePath; reporting never creates record IDs.
Closing/deleting an editor, replacing its engine/schema, or unmounting retires the old scope with onDiagnostics([], { ...meta, kind: "scope-ended" }). This means the scope is no longer observed, not that the saved row recovered. Only engines that actually evaluate are represented: the callback is not a complete submission-health report and does not re-evaluate closed rows.
Calculation diagnostics remain outside state.errors, saved snapshots and record payloads. They do not change validation display timing, submission decisions or draft saving, and do not create automatic alerts or a diagnostics panel.
onOperations(operations, meta, applyDefault) receives non-alert operations. Delegate selected operations with applyDefault(selected, meta); without a callback, existing default processing remains active. Renderer-owned ALERT dialogs appear once. Operation batches follow the existing platform processing; callbacks must handle every supplied batch. onWarning remains the legacy warning route, including its existing throttling, and is not a replacement for diagnostic snapshots. onUpdate(state, engine) retains the execution-specific engine/facade argument; use onDiagnostics for portable diagnostic consumption.
The managed event integration uses form0-core 0.8.1 or later. Lifecycle events default on.
The host must supply events.recordKind; missing classification is a configuration error.
Set lifecycle: false explicitly to preserve legacy root load/edit/change behavior.
<FormRenderer
schema={schema}
engineOptions={{
events: {
recordKind: 'existing', // explicitly 'new' or 'existing'; never inferred from values
},
onEventController: (controller) => {
controllerRef.current = controller;
},
}}
/>onEventController(controller | null) receives the current engine generation after initialization.
Callbacks can be replaced while editors are open. Lifecycle settings and record classification are
fixed for the editor session; remount to replace them, helpers or security configuration. Observer
exceptions and rejected promises are isolated.
Both controller methods return promises in every execution mode:
const detail = await controller.dispatch({
type: 'validate-record',
metadata: { isDraft: false, isValid: true },
});
// { status: 'completed' | 'failed', operations, validationIssues, issues }
const prepared = await controller.prepareSave({ intent: 'submit' });
if (prepared.eligible) await persistRecord(prepared.payload, prepared.meta);
// prepareSave never persists, reports persistence success, or closes an editor.prepareSave waits for accepted updates, validates, runs the pre-save hook once when policy permits,
awaits delegated operations, revalidates if editable values changed, then serializes that same final
snapshot. Built-in actions use this path when lifecycle integration is enabled; application buttons
can use the controller directly. Requests are serialized; concurrent preparation for the same scope
returns save_in_progress. Applications own deduplication of their subsequent persistence requests.
A failure returns eligible: false, payload: null and structured issues, preserving editable
values for correction. Normal submit and row acceptance respect validation failures. Root draft
preparation (intent: 'draft') remains allowed with validationSummary.hasErrors: true; row drafts
are unsupported. Root submit returns the structured record, root draft the existing raw snapshot,
and row preparation the existing row shape. meta retains submission/draft metadata.
ON('validate-record', function (event) {
if ($amount < 4) INVALID('Amount must be at least four.');
});
ON('save-record', function (event) {
SETVALUE('note', event.isDraft ? 'Draft reviewed' : 'Ready');
});INVALID(nonemptyMessage) is synchronous and validation-only. Findings are scope-level messages in
the existing validation summary/navigation; they have no invented field association and never enter
intrinsic state.errors, calculation diagnostics, saved values or record/media metadata. Every
validation attempt replaces custom findings; editable changes or scope retirement clear them.
Validation and cancel/unload handlers reject value/UI operations and promises. isDraft describes
the current attempted action; isValid describes intrinsic validity, with combined validity in the
returned summary. Calculation diagnostics alone never block saving.
Managed opening evaluates effective values, runs load, then new/edit when entering editing; readonly
opening runs load only. Rows use their actual new/existing classification and repeatable events.
User field edits notify change after updates settle; accepted row membership changes carry action
(add, edit, remove) and affectedInstancePath on the owning repeatable listener. Enabled status
fields notify change-status for user changes only. Defaults, SETVALUE and calculations emit no
additional automatic change. Interaction subscriptions default on; set fieldInteractions: [] to
opt out. Built-in text/numeric inputs emit focus and blur; choice controls use change. Automatic
click is reserved for supported activation fields. There is currently no HyperlinkField and no
built-in control automatically emits click. Explicit host controller dispatch remains available.
For a row request, supply instancePath or the active scopeId from diagnostics metadata. Full paths
use schema keys and existing row IDs, with explicit positional fallback; positional paths require
that observed active scopeId. Old controllers, closed/deleted editors and obsolete generations are
rejected. Open row handlers may read current main/ancestor values but SETVALUE writes only within
that row. Collections and closed-row freshness/aggregation remain outside this contract.
Confirmed renderer cancellation sends cancel then unload; supported successful close paths send
unload before releasing the engine. Host-owned closing must dispatch unload before unmounting.
Forced disposal, resets, rerenders and Strict Mode cleanup only retire scopes. save-record and
save-repeatable are pre-save hooks; there are no saved-* schema events. The application owns
persistence, success notifications and closing. Async onOperations work is awaited; its selective
applyDefault callback is valid only during that dispatch. Renderer alerts remain owned by the
binding and are delivered once.
Native File/Blob media handles retain standard metadata and immutable binary content. Custom properties on these handles are outside native serialization; attach custom metadata to the surrounding plain record object. Async schema handlers remain unsupported. The form0-core 0.5.1 minimum includes the media compatibility correction.
repeatableEntryMode accepts 'readonly' (default) or 'edit' for existing rows at every nesting
level. View mode offers Edit when the containing record permits editing. New rows start in edit
mode. Read-only parents allow browsing descendants but prevent editing, adding and removing them;
repeatableEntryMode="edit" does not override that restriction. This is a renderer option, with no
schema or saved-record change.
View mode displays supplied accepted calculations and evaluates conditions and intrinsic validation without recalculating or applying missing defaults. Edit starts calculations using current permitted ancestor values. For accepted child totals 110/220 and root 330, viewing keeps those values; editing line 1 computes 120, saving it produces root 340, and cancelling keeps root 330. Unsaved selections after persistence failure remain working values when switching back to View.
Managed opening awaits load, then new/edit for direct editing. View β Edit runs edit without replaying
load. Handlers receive event.mode: 'readonly' | 'edit' for their executing scope. View permits reads,
ALERT and engine-lifetime ON/OFF, but rejects a complete operation batch containing record writes
before host delegation. Guard editable-load setters or move writable initialization to new/edit.
Validation/close phases retain stricter restrictions. These capabilities require the coordinated
core release containing engine mode control and readonly scoped evaluation; no schema or record
migration is introduced.
Official setters and default SETVALUE operation application share core normalization. SingleChoice
and Boolean accept a string choice ID or { choice, other }; MultiChoice accepts string IDs in an
array or { choices, other }. Full live objects preserve labels and metadata. Schema choices and
submitted record formats remain unchanged. Updates do not implicitly dispatch form events.
Malformed calls reject before applying a value batch. Input aliases and numeric/boolean choice-ID coercion are intentionally no longer supported. Calculated/display/container targets, cross-scope writes and setter/event writes in record readonly mode are rejected. Host restoration and authoritative overrides remain separate loading paths. See the value-update contract.
Save failures are reported to console.error by default, with the failure stage and original
error. Failed persistence callbacks keep edits available for retry and are not acknowledged as
successful saves. Your application owns user-facing feedback: after catching a failed save to show
a message, rethrow the error. A fulfilled callback counts as success.
<FormRenderer
schema={schema}
engineOptions={{ events: { recordKind: 'new' } }}
submissionDiagnostics={{ saveFailures: true, validationBlocks: false }}
onSubmit={saveRecord}
/>Set saveFailures: false if your application already reports failures. Set validationBlocks: true
to log blocked Submit and repeatable-row Save attempts, such as an empty required field. Validation
feedback remains visible with logging off. These preferences stay current without rebuilding the
engine, including during a pending save; they are separate from debug, calculation diagnostics
and lifecycle settings. Reports do not attach form values, snapshots or record paths. Preparation
failures include issue codes/messages; cancelling a confirmation stays quiet. Exceptions from your
callback, including a rejected cancellation, are reported as callback failures. Existing core
event messages have their own behavior.
The settings apply to built-in renderer actions. Applications using prepareSave() directly must
inspect and report the returned result themselves.
The same controls cover draft onSave failures. Invalid drafts remain saveable and successful
draft saves do not produce validation-block messages.
eventDiagnostics controls managed event failure reporting separately from submissionDiagnostics:
<FormRenderer
schema={schema}
engineOptions={{ events: { recordKind: 'existing' } }}
eventDiagnostics={{ failures: true }}
submissionDiagnostics={{ saveFailures: true, validationBlocks: false }}
/>Failed managed events report once to console.error by default, including rejected SETVALUE()
operations and rejected onOperations promises. Reports contain the event type, triggering field,
root/row scope and issue codes/messages, without attaching values, operation arguments, snapshots or
record IDs. Set failures: false when your application handles these reports. The preference stays
current in open editors and pending operations without rebuilding or evaluating the engine.
Normal field validation and INVALID() findings remain separate. Cancelled or retired work stays
quiet. Built-in Submit and row Save preparation use the submission reporter instead of emitting a
second event report. Hosts calling prepareSave() directly still inspect its returned result.
dispatch() continues returning its structured result even when console reporting is disabled.
Existing core warnings and legacy lifecycle: false console behavior are unchanged.
Numeric fields still accept numbers: SETVALUE("quantity", 1) is valid. Choice fields require string
IDs: SETVALUE("rating", "1") for single selection and SETVALUE("tags", ["1"]) for multiple
selection. A rejected call leaves its operation batch unapplied; reporting does not change values,
validation, submitted records or evaluation counts.