dynamic-form-engine-react
v3.0.3
Published
Schema-driven dynamic form engine for React — JSON-configured forms with built-in validation, calculated fields, conditional visibility, and wizard support.
Maintainers
Readme
dynamic-form-engine-react
Server-driven dynamic form engine for React. Forms are described entirely as JSON (a FormSchema)
and rendered, validated, and wired to actions at runtime — no per-form React components to write
or ship. A schema can come from an API, a CMS, or a static file; the engine only needs the shape
described below.
Installation
npm install dynamic-form-engine-reactPeer dependencies: react >= 18, react-dom >= 18.
Quick start
import { FormEngine } from 'dynamic-form-engine-react'
import type { FormSchema } from 'dynamic-form-engine-react'
const schema: FormSchema = {
form: {
form_id: 1,
form_code: 'contact',
form_title: 'Contact',
form_description: 'Basic contact form',
columns: 2,
on_submit_function: 'contact.submit',
is_wizard: false,
wizard_parent_id: null,
step_number: null,
step_title: null,
custom_component: null,
sections: [],
fields: [1, 2],
},
sections: [],
fields: [
{
field_id: 1,
field_code: 'name',
field_label: 'Full name',
field_placeholder: 'Jane Doe',
field_type: 'text',
field_order: 1,
column_span: 2,
is_required: true,
config: { maxLength: 100 },
validation_rules: null,
section_id: null,
description: null,
is_visible: true,
},
{
field_id: 2,
field_code: 'email',
field_label: 'Email',
field_placeholder: '[email protected]',
field_type: 'email',
field_order: 2,
column_span: 2,
is_required: true,
config: { validation: 'email' },
validation_rules: null,
section_id: null,
description: null,
is_visible: true,
},
],
wizard_info: null,
}
function ContactForm() {
return (
<FormEngine
schema={schema}
mode="create"
layoutMode="default"
onSubmit={(values) => console.log('submitted', values)}
/>
)
}Core concepts
- Schema-driven. A
FormSchemafully describes a form: its layout, its fields, their validation, and what happens on submit. The engine never needs form-specific code. - Normalization. Raw schemas use numeric IDs and a
configfield that is a real, nestedFieldConfigobject (JSON-serializable, arbitrarily nested). On mount,parseSchemanormalizes this into aNormalizedSchemawhere every field'sconfigis exposed asparsedConfigand fields are sorted byfield_order. - Sections vs. root fields. A form can mix root-level fields (
schema.fields, rendered directly in the form's grid) and fields grouped into collapsiblesections. Both are normalized and validated identically. - Everything else — validation, calculated fields, conditional visibility, actions, wizard steps — is declared entirely in the JSON, not in application code.
Form schema reference
FormDefinition
| Field | Type | Meaning |
|---|---|---|
| form_id | number | Numeric identifier. |
| form_code | string | Stable string identifier for the form. |
| form_title / form_description | string | Display metadata. |
| columns | 1 \| 2 \| 3 | Grid columns for root-level fields and each section. |
| on_submit_function | string | Action name dispatched when the form is submitted. See Actions. |
| is_wizard | boolean | Whether this schema should render as a multi-step wizard. Requires wizard_info. |
| custom_component | string \| null | Name of a custom component (registered via CustomComponentRegistryService) rendered above the form body. |
| sections / fields | number[] | IDs of the sections/root fields belonging to this form (informational — actual content comes from schema.sections/schema.fields). |
SectionDefinition
Groups fields under a titled, optionally collapsible panel. is_collapsible + is_collapsed
control the initial state; a section's body is only mounted the first time it is expanded
(hasBeenExpanded), so collapsed sections never pay the render cost until opened.
FieldDefinition
| Field | Type | Meaning |
|---|---|---|
| field_code | string | The key used in formValues/errors, and the identifier formulas and conditions reference. |
| field_type | string | Which renderer to use — see Field types. |
| is_required | boolean | Enforced by the built-in required check (see Validation). |
| column_span | number | How many of the form/section's columns this field occupies. |
| config | FieldConfig | Field-type-specific configuration (see below) — a real nested object, not a string. |
| section_id | number \| null | null for root-level fields. |
| is_visible | boolean \| null | Static default visibility, overridden by config.conditional if present. |
FieldConfig (the config object)
| Property | Type | Purpose |
|---|---|---|
| options | SelectOption[] | Options for select fields. |
| dependsOn / dependencyFilter | string / { matchKey, sourceKey } | When both are set, filters a select's options to those whose dependsValue equals the dependsOn field's current value. dependencyFilter's own shape is only checked for presence today — the match is always against SelectOption.dependsValue. |
| maxLength / minLength | number | Length bounds (validated on submit and live, see below). |
| pattern | string | Regex source string checked against the field's value. |
| min / max | number | Numeric bounds. |
| rows | number | Rows for textarea. |
| validation | 'email' \| 'integer' \| 'numeric' \| string | Name of a registered validator to run (see Validators). |
| conditional | ConditionalRule | Show/hide rule — see Conditional visibility. |
| editableOn | ('create' \| 'edit')[] | Restricts editing to the listed modes. An empty array ([]) makes the field permanently read-only regardless of mode — the standard way to mark a calculated/derived field non-editable. |
| events | Record<string, FieldEvent> | Per-event actions — see Events and actions. |
| params | Record<string, unknown> | Free-form parameters read by whichever action the field's events reference (e.g. calculateValue's calculations array). |
| text / variant | string | Label and visual style for button/info fields. |
| initialValue | unknown | Default value used when no initialValues prop supplies one. |
| minDate / maxDate | string | ISO date (or datetime-local) bounds for date/datetime fields. |
| repeater | RepeaterConfig | Configuration for repeater fields — see Repeater fields. |
Field types
Resolved via FieldRegistryService and lazy-loaded per type (React.lazy), so a form only
downloads the code for the field types it actually uses:
| Type | Component | Notes |
|---|---|---|
| text | TextField | Plain text input. Honors maxLength/minLength/pattern. |
| number | NumberField | Numeric input. Honors min/max. Empty value is treated as null, not 0. |
| email | EmailField | Text input intended for email addresses. |
| url | UrlField | Text input intended for URLs. |
| textarea | TextareaField | Multi-line text. Honors rows. |
| select | SelectField | Dropdown from config.options, optionally filtered by dependsOn/dependencyFilter. |
| date | DatepickerField | Native <input type="date">. Value is always a plain ISO YYYY-MM-DD string. Honors minDate/maxDate. |
| datetime | DatepickerField | Native <input type="datetime-local">. Value is a plain YYYY-MM-DDTHH:mm string. Honors minDate/maxDate. |
| repeater | RepeaterField | A repeatable group of child records — table rows or embedded mini-forms. See Repeater fields. |
| button | ButtonField | Not a data field — triggers an action on click (see Events and actions). Styled via variant. |
| info | InfoField | Static informational text, not part of formValues. |
| blank-space | BlankSpace | Empty grid cell, for layout spacing. |
| (any other string) | FallbackField | Rendered when field_type has no registered renderer — never a hard failure. |
Register a custom field type at application startup:
import { registerField } from 'dynamic-form-engine-react'
registerField('signature', () => import('./fields/SignatureField'))The factory must resolve to a component matching FieldProps (select fields additionally
receive filteredOptions).
Repeater fields
A repeater field renders a repeatable group of child records — for any header-plus-child-
collection relationship (a parent record together with a one-to-many list of related records),
not a single flat record. Its value is always an array of plain value objects, one per item,
stored under the field's own field_code exactly like any other field's value.
{
"field_code": "entries",
"field_type": "repeater",
"is_required": true,
"config": {
"repeater": {
"presentation": "table",
"minItems": 1,
"addLabel": "+ Add entry",
"itemSchema": {
"fields": [
{
"field_id": 101, "field_code": "option_id", "field_label": "Option",
"field_type": "select", "field_order": 1, "column_span": 1, "is_required": true,
"config": {
"dependsOn": "parent.category_id",
"dependencyFilter": { "matchKey": "value", "sourceKey": "value" },
"options": ["..."]
},
"validation_rules": null, "section_id": null, "description": null, "is_visible": true
},
{
"field_id": 102, "field_code": "count", "field_label": "Count",
"field_type": "number", "field_order": 2, "column_span": 1, "is_required": true,
"config": {
"min": 1,
"events": { "onChange": { "action": "calculateValue" } },
"params": { "calculations": [{ "formula": "count * unit_amount", "targetField": "subtotal" }] }
},
"validation_rules": null, "section_id": null, "description": null, "is_visible": true
}
]
}
}
}
}RepeaterConfig
| Property | Type | Purpose |
|---|---|---|
| presentation | 'table' \| 'cards' | 'table' renders a compact data-grid, one <tr> per item, columns from itemSchema.fields. 'cards' renders each item as its own embedded mini-form (see below), for children too rich for a single table row. |
| itemSchema | RepeaterItemSchema | { fields, sections?, columns? } — same wire shape as a top-level FormSchema's fields/sections (a FieldDefinition[], config as a real nested object, same as everywhere else). sections and multi-column columns are only meaningful for 'cards'; 'table' renders itemSchema.fields as flat columns. |
| minItems / maxItems | number | Item-count bounds. minItems defaults to 1 when the field itself is is_required: true and no explicit minItems is set, otherwise 0. |
| addLabel / removeLabel | string | Button text. Default "+ Add item" / "✕". |
| emptyLabel | string | Message shown when there are zero items. |
| itemTitleField | string | 'cards' only — a field_code from the item's own values used as that card's title (falls back to "Item N"). |
| itemsCollapsedByDefault | boolean | 'cards' only — whether each item card starts collapsed. |
Table vs. cards presentation
'table'— each column is one entry ofitemSchema.fields, rendered through the sameFieldRendererevery other field type goes through (so any field type, includingselect,date, or even a nestedrepeater, works as a column). Validation, calculated-field formulas, and per-itemdependsOnfiltering all work exactly as they do for a top-level form field, just scoped to that row's own values.'cards'— each item renders as a small, independent<DynamicForm>(so it can useitemSchema.sections, multi-column layout viaitemSchema.columns, everything a top-level form can do) inside a collapsible card shell. This is genuinely "a form within a form," not a simplified sub-renderer — reuse the full schema vocabulary here when a child record needs more than a row of scalar fields.
Parent-scoped dependsOn
A column/item field's dependsOn may be prefixed "parent." (e.g. "parent.category_id") to
resolve its dependencyFilter against the enclosing form's values instead of the item's own —
so an item's own select can filter by a field on the parent/header form. An unprefixed dependsOn
(e.g. "unit") keeps resolving against the item's own sibling columns, as usual. This applies to
dependsOn/dependencyFilter (select filtering) only — config.conditional (show/hide) is always
scoped to the item's own values.
Validation
is_required on the repeater field itself means "at least one item" (or minItems, if set — see
above). Each item is additionally validated against itemSchema using the same validation rules
as a top-level form; if any item is invalid, the repeater field's entry in the form's errors map
becomes a summary ("N of M items have errors") and the form fails submission — inline, per-field
errors are still shown live inside each row/card as the user types.
Validation
Validation runs field-by-field, in this fixed order, stopping at the first failure per field
(validateField in core/validation/validationEngine.ts):
repeaterfields are checked separately, againstminItems, and never fall through to steps 1–6 below — see Repeater fields → Validation.- Required — if
is_requiredand the value isnull/undefined/''. - Empty optional fields skip every remaining check.
- Pattern —
config.patternas a regex againstString(value). - Date bounds —
config.minDate/config.maxDate(date/datetimefields), compared as plain ISO strings. - Named validator —
config.validation, resolved from the validator registry (see below). - Min / Max — numeric bounds via
config.min/config.max. - Max length —
config.maxLengthagainstString(value).length.
Hidden fields (per conditional visibility) are always skipped — a field that is not shown can never block submission.
When validation runs
- On submit.
FormEnginevalidates the entire form before invokingonSubmitor the schema'son_submit_functionaction — an invalid form never reaches your submit handler. For wizard forms, each step is additionally validated before advancing (FormHolder'svalidateCurrentStep), so a user cannot skip past required fields on an earlier step. - On blur. Every field is validated as soon as it loses focus, independent of submission.
- On change, conditionally. Once a field has a visible error, it re-validates on every keystroke so the message clears the moment the value becomes valid — fields without an existing error do not re-validate on every keystroke, to avoid flashing "required" while the user is still typing.
- Imperatively, via the
EngineHandle.validate()ref method (see Public API).
Validators
Built-in named validators (config.validation):
| Name | Rule |
|---|---|
| email | /^[^\s@]+@[^\s@]+\.[^\s@]+$/ |
| integer | Number.isInteger(Number(value)) |
| numeric | /^\d+$/ |
Register a custom validator at application startup:
import { registerValidator } from 'dynamic-form-engine-react'
registerValidator('nit', (value) => {
return isValidNit(String(value)) ? null : 'Invalid NIT'
})A validator returns null when the value is valid, or an error message string otherwise. Set
config.validation to the registered name to use it. A validator function's second parameter is
the active locale (see Localization) — a custom validator can ignore it
(its message stays in whatever language it's hardcoded in) or use it to return a localized message
itself.
Localization (i18n)
<FormEngine locale="es"> (or "en" / "fr", default "en") selects the language for every
built-in validation error message — required, pattern, min/max, min/max length, date bounds,
repeater item-count and per-item-error summaries, and the three built-in named validators
(email/integer/numeric). This is deliberately narrow in scope: the engine never generates
any other user-facing text. Field labels, placeholders, descriptions, button text, repeater
addLabel/removeLabel/emptyLabel, and wizard navigation copy all come from the schema (or the
host app's own strings/i18n layer) — the engine only owns the messages it writes itself, which are
exactly the validation errors above.
<FormEngine schema={schema} locale={currentAppLocale} /* 'en' | 'es' | 'fr' | ... */ />Built in: en, es, fr. An unrecognized locale string, or a locale with a partial/missing
catalog, always falls back to the en message for whatever key it's missing — a typo'd or
not-yet-supported locale never produces a blank or broken error message.
Add a language the engine doesn't ship, or override individual built-in messages, via
registerLocale (mirrors registerField/registerValidator):
import { registerLocale } from 'dynamic-form-engine-react'
registerLocale('de', {
required: () => 'Dieses Feld ist erforderlich',
invalidFormat: () => 'Ungültiges Format',
// ...only override the keys you need — anything left out still resolves from 'en'.
})Message keys
Each catalog entry is (params) => string, so a registered override can interpolate however it
needs to (pluralization, word order, etc.) rather than filling a fixed template string.
| Key | params | English (built-in) |
|---|---|---|
| required | — | "This field is required" |
| invalidFormat | — | "Invalid format" |
| minValue | { min } | "Minimum value is {min}" |
| maxValue | { max } | "Maximum value is {max}" |
| maxLength | { maxLength } | "Maximum length is {maxLength}" |
| minDate | { date } | "Date must be on or after {date}" |
| maxDate | { date } | "Date must be on or before {date}" |
| minItems | { min } | "At least {min} item(s) required" |
| itemsHaveErrors | { invalidCount, total } | "{invalidCount} of {total} item(s) have errors" |
| invalidEmail | — | "Invalid email address" |
| mustBeInteger | — | "Must be an integer" |
| mustBeNumeric | — | "Must be numeric" |
Conditional visibility
config.conditional hides or shows a field based on another field's current value:
{
"conditional": {
"action": "show",
"dependsOn": "has_shipping_address",
"condition": { "operator": "equals", "value": true }
}
}action is "show" (visible when the condition is true) or "hide" (visible when the condition
is false). Supported operators: equals, not_equals, contains (substring or array membership),
greater_than, less_than, is_empty, is_not_empty. A hidden field is automatically treated
as not required, regardless of is_required, so it never blocks submission.
Calculated fields (formulas)
The engine includes a small, dependency-free arithmetic evaluator (evaluateFormula) supporting
+ - * /, parentheses, unary minus, and decimals, with field codes substituted from the current
form values:
{
"field_code": "units_per_package",
"config": {
"events": { "onBlur": { "action": "calculateValue" } },
"params": {
"calculations": [
{ "formula": "packages * units_per_package", "targetField": "total_units" }
]
}
}
}Rules:
calculateValueis a built-in action name — the engine handles it directly, no registration needed.- The calculation list lives at
config.params.calculations— a field's ownparams, not nested inside the event object. Each entry is{ formula, targetField }. - Attach the
calculateValueevent to every field a formula reads from — recalculation only happens when one of the source fields fires the configured event, not when the target field changes. - A formula referencing a missing/empty/non-numeric field substitutes
0for it. Division by zero, or any malformed formula, yieldsnull— the target field is left unchanged rather than set toNaN/Infinity. - Mark the target field read-only with
"editableOn": [], so users cannot manually override a derived value.
By default calculations run on onBlur. A field can also opt into onChange for live
recalculation as the user types:
"events": {
"onChange": { "action": "calculateValue" },
"onBlur": { "action": "calculateValue" }
}onChange-triggered actions are debounced (250ms from the last keystroke) so rapid typing
triggers one recalculation, not one per character; blurring the field cancels any pending
debounced calculation.
Events and actions
A field's config.events maps an event name to { action, when? }:
onBlur— fires when the field loses focus. Always wired.onChange— fires (debounced) on every value change. Opt-in per field — only dispatched if the field's config declares it.when— optional; if set to'create'or'edit', the event only fires in that form mode.
Two action names are handled internally and never need to be registered:
calculateValue— runs the field's formulas, see above.redirectAction— opensparams.url(window.open;params.downloadcontrols_selfvs._blank).
Any other action name is looked up in the actions registry passed to <FormEngine actions={...}>
and called with an ActionContext:
interface ActionContext {
formValues: Record<string, unknown>
fieldDefinition?: FieldDefinition | NormalizedField
eventSource?: string
stepMetadata?: { currentStep: number; totalSteps: number }
params?: Record<string, unknown>
}An action name with no matching handler logs a console warning and is otherwise a no-op — a missing action never crashes the form.
Submitting a form
schema.form.on_submit_function is the action name dispatched on submit — typically wired to a
button field via config.events.onBlur.action (buttons dispatch their click through the same
onBlur-style handler; there is no separate click event). FormEngine's onSubmit prop is
invoked with the current form values only if validation passes; it runs before any consumer
action registered under the same name.
Wizard mode
Set form.is_wizard: true and supply wizard_info (current_step, total_steps, and one
WizardStep per step, each with its own form/sections/fields) to render a multi-step form
with built-in Previous/Next/Submit navigation and a step indicator. Each step's fields are
validated before advancing; the final step's submit still goes through the same full-form
validation and on_submit_function dispatch as a non-wizard form.
Theming
<FormEngine theme={...}> accepts a partial ThemeTokens object, merged over defaultTheme and
exposed as CSS custom properties (--dfe-color-primary, --dfe-spacing-field-gap, etc.) on a
wrapping <div>, so every field component and the modal shell read from the same token set.
Passing CSS variable references (e.g. colorPrimary: 'var(--primary)') lets a host application
bridge its own design system, including automatic light/dark mode support, without the engine
needing to know about it.
Rendering modes
inline(default) — renders directly where<FormEngine>is placed.modal— wraps the form in a draggable, closable modal (modalOptions:width,height,draggable,backdropOpacity,title,onClose). Button fields are automatically pulled into a fixed footer instead of scrolling with the rest of the form.
Custom form-level components
form.custom_component names a component registered via CustomComponentRegistryService,
rendered above the form body — for content that isn't expressible as a field (banners, summaries,
embedded widgets):
import { CustomComponentRegistryService } from 'dynamic-form-engine-react'
CustomComponentRegistryService.register('planUpgradeBanner', () => import('./PlanUpgradeBanner'))Public API
<FormEngine> props
| Prop | Type | Purpose |
|---|---|---|
| schema | FormSchema | Required. Re-normalized whenever it changes. |
| mode | 'create' \| 'edit' | Governs editableOn and event when guards. Defaults to 'create'. |
| layoutMode | 'default' \| 'erp' | 'erp' renders labels beside inputs in a fixed-width column; 'default' stacks label above input. |
| renderMode | 'inline' \| 'modal' | See Rendering modes. |
| locale | 'en' \| 'es' \| 'fr' \| string | Language for built-in validation error messages. Defaults to 'en'. See Localization. |
| actions | ActionRegistry | Consumer-supplied action handlers, keyed by action name. |
| theme | Partial<ThemeTokens> | See Theming. |
| modalOptions | ModalOptions | Only used when renderMode === 'modal'. |
| initialValues | Record<string, unknown> | Seed values, e.g. when editing an existing record. |
| onValidationChange | (result: ValidationResult) => void | Called whenever validation state changes. |
| onSubmit | (values: Record<string, unknown>) => void | Called after validation passes, before the on_submit_function consumer action (if registered). |
EngineHandle (via ref)
interface EngineHandle {
getValues: () => Record<string, unknown>
setValues: (partial: Record<string, unknown>) => void
validate: () => ValidationResult
reset: () => void
goToStep: (n: number) => void
}Architecture notes
- No external runtime dependencies — the arithmetic evaluator, condition evaluator, and validators are all hand-written, dependency-free code.
- Field renderers are lazy-loaded per type so unused field types never ship to the browser.
parseSchema/normalizeFieldrun once per schema change (useMemo), not per render.- All engine state (
formValues,validationState,currentStep, collapsed sections) lives in a singleuseFormEnginehook;FormEngineContextexposes read access plus the handful of mutation functions (setFieldValue,validateField,setErrors,dispatchAction) that field and layout components need — there is no external state management dependency.
