TL;DR: FormEngine separates form structure (JSON schema) from rendering (pluggable UI adapter). At runtime, FormViewer resolves each schema entry to a React component via ComponentStore, maintains a reactive IFormData state object, evaluates renderWhen conditions on every change, runs validators on interaction, and fires action sequences on events.
Last reviewed: April 2026.
This page explains how FormEngine Core works internally — useful for debugging unexpected behavior, building custom components, understanding performance characteristics, or evaluating whether FormEngine fits a complex use case.
FormEngine Core source is on GitHub (MIT).
The two-layer model
FormEngine has a strict separation between two concerns:
Schema layer — a JSON object that describes what the form contains: which components, in what order, with what props, validation rules, conditions, and actions. The schema is serializable, storable, and framework-agnostic.
View layer — a package that maps component type names in the schema to actual React components. @react-form-builder/components-material-ui maps "Input" to MUI’s TextField, "Dropdown" to MUI’s Select, and so on. Swap the view, and the same schema renders with a different component library.
JSON Schema → FormViewer → ComponentStore → React Components
↓
IFormData (reactive state)
You can ship the same schema to multiple apps using different UI libraries — the schema doesn’t change, only the view adapter does.
ComponentStore
ComponentStore is the registry that maps schema type names to component descriptors. A descriptor defines:
- The React component to render
- Which props are available and their types
- Which events the component fires (
onChange, onClick, etc.)
- Which data type the component binds to (
string, number, boolean, array)
- Default prop values
import { ComponentStore } from '@react-form-builder/core'
import { muiComponents } from '@react-form-builder/components-material-ui'
const store = new ComponentStore(muiComponents)
When FormViewer encounters "type": "Input" in a schema, it asks ComponentStore to resolve that type name and returns the registered React component with its descriptor. This is why swapping the view changes the rendering — you’re changing what ComponentStore resolves type names to.
Custom components are registered the same way:
import { ComponentStore, defineComponent } from '@react-form-builder/core'
const myInput = defineComponent({
name: 'MyInput',
component: MyInputComponent,
props: {
label: { type: 'string', defaultValue: '' },
placeholder: { type: 'string', defaultValue: '' },
},
dataType: 'string',
events: ['onChange', 'onBlur'],
})
const store = new ComponentStore([...muiComponents, myInput])
FormViewer is the top-level React component. It takes the schema string, a ComponentStore (via the view prop), and optional callbacks.
On mount:
- Parses the JSON schema string into an internal tree
- Initializes
IFormData with initialData if provided, otherwise empty values
- Walks the component tree, resolving each
type against ComponentStore
- Renders the resolved React components, injecting the current
form state as context
On every state change (user types, value changes, action fires):
- Updates
IFormData
- Re-evaluates all
renderWhen conditions
- Re-evaluates all computed properties
- Updates the rendered tree with new visible/hidden states and computed values
This is an incremental update — FormEngine doesn’t re-render the entire tree on every keystroke. It uses React’s reconciliation along with internal memoization to update only what changed.
IFormData is the central state object that everything in FormEngine reads from. It’s available in renderWhen expressions, computed properties, actions, and custom validators as the form variable.
interface IFormData {
data: Record<string, unknown> // current field values
errors: Record<string, unknown> // validation errors by field key
hasErrors: boolean // true if any field has an error
state: Record<string, unknown> // custom workflow state (your namespace)
parentData?: Record<string, unknown> // parent item when inside Repeater
rootData: Record<string, unknown> // root-level data (same as data outside Repeater)
index?: number // current item index inside Repeater
}
form.data is flat — all fields across all wizard steps, repeater items’ parent scope, etc. write to the same namespace. Repeater items have their own form.data scope within the item, with access to the parent via form.parentData.
You can read IFormData programmatically via the FormViewerRef:
const ref = useRef<FormViewerRef>(null)
const snapshot = ref.current?.getFormData()
renderWhen: the condition pipeline
Every component optionally has a renderWhen property. FormEngine evaluates it after every state change using a two-pass approach:
-
Topological sort — conditions that depend on each other are evaluated in dependency order. A field that’s
renderWhen: "form.data.A === true" and another that’s renderWhen: "form.data.B === true && form.data.A === true" are evaluated A-first.
-
Evaluation — each condition runs in a sandboxed JS context with
form bound to the current IFormData. The expression must return boolean true to render; anything else hides the component.
Two condition formats:
// Expression (no return keyword)
"renderWhen": "form.data.accountType === 'business'"
// Function (return required, allows multi-line logic)
"renderWhen": {
"computeType": "function",
"value": "const t = form.data.total; return t > 100 && form.data.hasPromo === true"
}
What “hidden” means:
- The component is unmounted from the React tree
- Its validations do not run
- Its value in
form.data is preserved (not cleared)
- If you need to clear the value, use a
setValues action on the controlling field
Computed properties
A computed property is any component prop with computeType: "function". Instead of a static value, the prop’s value is recalculated on every state change.
{
"name": "totalLabel",
"type": "Label",
"props": {
"text": {
"computeType": "function",
"value": "return 'Total: $' + (form.data.quantity * form.data.price).toFixed(2)"
}
}
}
Computed properties run after renderWhen in the evaluation cycle, so they can safely reference whether other fields are visible.
Performance note: computed properties run on every state change. Keep them simple. Avoid computed properties that read from many fields or perform expensive calculations — they will run on every keystroke in any field.
Validation pipeline
Validation runs when:
- A field’s value changes (automatic validation, enabled by default)
- The form’s
validate action fires (e.g., on submit button click)
- A Wizard’s “Next” step button is pressed with
validateOnNext: true
For each field:
- Check if the field is visible (hidden fields skip validation entirely)
- Run validators in the order they appear in the
validations array
- Stop at the first failing validator and set
form.errors[fieldKey]
- If all pass, clear
form.errors[fieldKey]
Validators are resolved from ComponentStore by type name. Built-in validators (required, email, min, max, etc.) are registered automatically. Custom validators are registered via the validators prop on FormViewer:
const validators: FormViewerProps['validators'] = {
string: {
myRule: {
validate: (value, store, args, formData) => {
return someCheck(value) ? true : 'Error message'
},
validateAsync: async (value, store, args, formData) => {
const ok = await checkServer(value)
return ok ? true : 'Taken'
}
}
}
}
Async validators (validateAsync) run after all sync validators pass. They do not block UI interaction — errors appear when the async check completes.
Action system
Actions are named operations that execute in response to events. Built-in actions include submit, reset, setValues, validate, and navigate. Custom actions are registered on FormViewer:
<FormViewer
view={view}
form={schema}
actions={{
sendToSlack: async (args, form) => {
await fetch('/api/slack', { method: 'POST', body: JSON.stringify(form.data) })
}
}}
onActionEventAsync={async (event) => {
if (event.name === 'submit') {
await handleSubmit(event.args.formData)
}
}}
/>
In the schema, actions are bound to component events:
{
"name": "submitBtn",
"type": "Button",
"props": { "label": "Submit" },
"actions": [
{ "event": "onClick", "action": "validate" },
{ "event": "onClick", "action": "submit" }
]
}
Actions in a sequence execute in order. If validate fails (sets form.hasErrors = true), submit can check that and short-circuit. The submit built-in action fires onActionEventAsync with event.name === 'submit' and event.args.formData containing all current field values.
View adapter: how UI swapping works
The view prop accepts a View object — a record of component type names to descriptors, plus global settings (tooltip wrapper, error wrapper, etc.).
import { view as muiView } from '@react-form-builder/components-material-ui'
import { view as mantineView } from '@react-form-builder/components-mantine'
// Same schema, different UI:
<FormViewer view={muiView} form={schema} />
<FormViewer view={mantineView} form={schema} />
When you pass muiView, ComponentStore is initialized with MUI components. "Input" resolves to MuiTextField. When you pass mantineView, "Input" resolves to Mantine’s TextInput. The schema doesn’t know or care which it is — it just says "type": "Input".
The practical implication: you can have a single form schema shared across a React web app (MUI), a React admin panel (Mantine), and a React Native embed (custom adapter) — all rendering from identical JSON.
Repeater internals
Repeater manages an array in form.data. Each item in the array gets its own rendering context with a scoped IFormData:
form.data inside an item is the item’s own key/value pairs
form.parentData is the outer form.data
form.rootData is always the top-level form data
form.index is the item’s position in the array
Validation inside a Repeater runs per-item. form.errors inside an item scope refers to errors for that item’s fields only.
| Operation | Cost | Notes |
|---|
| Initial render | Medium | Schema parse + ComponentStore resolution + React mount |
| Field change | Low | Updates form.data, re-evaluates conditions + computed props |
renderWhen evaluation | Very low | Pure JS expression, no DOM |
| Validation (sync) | Low | Runs in order, stops at first error |
| Validation (async) | Network-bound | Runs after sync pass completes |
| Computed property | Low per property | Runs every state change — keep logic simple |
| Repeater with many items | Scales with item count | Each item re-evaluates on parent change |
The biggest performance risk is a large number of computed properties that run on every field change. Use renderWhen (pure condition) instead of computed properties wherever you only need to show/hide — it’s cheaper.
Next steps
Last modified on April 15, 2026