Skip to content

Repository files navigation

form0-react

NPM Version NPM Downloads CI NPM License Docs Website NPM Last Update Socket

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 is the React UI layer for the form0 ecosystem. It combines the form0-core engine with accessible default field renderers while allowing applications to replace the presentation with their own components and design system.

πŸš€ Start with the CLI

For a new project, install form0-cli and follow the quickstart. Choose the web application option to start from the maintained React and Vite template.

Install form0-react directly when integrating it into an existing React application.

πŸ“¦ Installation

npm install form0-react form0-core

Install compatible React peer dependencies if the application does not already provide them.

⚑ Quick example

import { FormRenderer } from 'form0-react';
import 'form0-react/index.css';

const schema = {
  form: {
    name: 'Contact form',
    status_field: null,
    elements: [
      {
        type: 'TextField',
        key: 'name',
        data_name: 'name',
        label: '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 ContactForm() {
  return (
    <FormRenderer
      schema={schema}
      engineOptions={{ events: { recordKind: 'new' } }}
      onSubmit={(record) => console.log(record)}
    />
  );
}

Custom renderers

Applications can replace or extend field components without forking the package. Use the exported field registry APIs or a FieldRegistryProvider to keep application-specific UI outside the form engine.

FormRenderer also supports consumer-owned state and layout integration:

  • headerAccessory renders application UI between the form summary and body.
  • externalDirty includes application-owned changes in discard confirmation.
  • submitBlockedReason prevents submission while an external condition is unresolved.
  • recordMetadataFields can provide read-only display data that never enters engine values.

βœ… Requirements

  • Node.js 22 or newer
  • React 18 or 19
  • React DOM 18 or 19
  • @vanilla-extract/css 1.17.4 or newer

Worker mode requires a bundler that supports ESM module workers and rewrites new Worker(new URL(..., import.meta.url), { type: 'module' }). Vite-based applications are supported. FormRenderer uses the packaged worker when engineMode="worker"; custom worker URLs and factories are not part of its public API.

πŸ“š Documentation

πŸ”’ Security

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.

🀝 Support and contributing

See SUPPORT.md for help and CONTRIBUTING.md to contribute.

πŸ“„ License

MIT

Engine options and calculation diagnostics

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.

Engine options in worker mode

Application callbacks (onDiagnostics, onWarning, onUpdate, onOperations) stay on the host; they are not serialized into the worker. Schema, values and engine configuration must be structured-cloneable. Function-valued helpers and other non-cloneable configuration are rejected with a clear initialization error before a request is dispatched: use engineMode="main-thread" for function helpers. Configuration failure does not silently change the requested mode or drop helpers. The existing fallback when Worker support is unavailable remains unchanged. Diagnostic messages are accepted once per active scope/evaluation; obsolete generations cannot restore old findings. See FormRenderer for the shared interface.

If replacing the schema or initial seed while a worker is still initializing, remount FormRenderer to initialize the replacement predictably. The existing in-flight initialization guard is retained: obsolete diagnostic scopes end immediately, but reporting does not schedule an extra evaluation. An empty active diagnostic collection does not establish that the replacement has evaluated.

Managed events and save preparation

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.

Worker execution transports cloneable requests/results and keeps application callbacks and alerts on the host. Function-valued helpers and non-cloneable configuration fail before dispatch; use the main thread for executable helpers. Event replies are consumed once and stale generations are rejected. No Native worker mode is added.

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.

Repeatable entry opening mode

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.

Releases

Sponsor this project

Packages

Used by

Contributors

Languages