npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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-react

Peer 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 FormSchema fully 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 config field that is a real, nested FieldConfig object (JSON-serializable, arbitrarily nested). On mount, parseSchema normalizes this into a NormalizedSchema where every field's config is exposed as parsedConfig and fields are sorted by field_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 collapsible sections. 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 of itemSchema.fields, rendered through the same FieldRenderer every other field type goes through (so any field type, including select, date, or even a nested repeater, works as a column). Validation, calculated-field formulas, and per-item dependsOn filtering 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 use itemSchema.sections, multi-column layout via itemSchema.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):

  1. repeater fields are checked separately, against minItems, and never fall through to steps 1–6 below — see Repeater fields → Validation.
  2. Required — if is_required and the value is null/undefined/''.
  3. Empty optional fields skip every remaining check.
  4. Patternconfig.pattern as a regex against String(value).
  5. Date boundsconfig.minDate/config.maxDate (date/datetime fields), compared as plain ISO strings.
  6. Named validatorconfig.validation, resolved from the validator registry (see below).
  7. Min / Max — numeric bounds via config.min/config.max.
  8. Max lengthconfig.maxLength against String(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. FormEngine validates the entire form before invoking onSubmit or the schema's on_submit_function action — an invalid form never reaches your submit handler. For wizard forms, each step is additionally validated before advancing (FormHolder's validateCurrentStep), 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:

  • calculateValue is a built-in action name — the engine handles it directly, no registration needed.
  • The calculation list lives at config.params.calculations — a field's own params, not nested inside the event object. Each entry is { formula, targetField }.
  • Attach the calculateValue event 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 0 for it. Division by zero, or any malformed formula, yields null — the target field is left unchanged rather than set to NaN/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 — opens params.url (window.open; params.download controls _self vs. _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/normalizeField run once per schema change (useMemo), not per render.
  • All engine state (formValues, validationState, currentStep, collapsed sections) lives in a single useFormEngine hook; FormEngineContext exposes 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.