Rules Engine
Model dynamic form behavior with JSONLogic conditions and rule effects.
The Rules Engine is the behavior layer of a Schemaform Form Definition. It evaluates persisted Rules against the current Working State and produces directives that the Form Renderer uses for visibility, enabled state, and required state.
A Rule is data:
{
condition: { "==": [{ var: "plan" }, "business"] },
effect: "show",
target: "company-details",
}The condition is JSONLogic. The effect is one of the v1 effects. The target is either a Control scope such as #/properties/email, or a layout node / Page id such as company-details.
JSONLogic conditions
Rule conditions use JSONLogic so they can be stored as JSON, evaluated safely, and round-tripped through a visual condition editor. A condition reads from Working State with var:
{ "==": [{ var: "plan" }, "business"] }That condition is true when workingState.plan is "business". Conditions can also combine checks:
{
"and": [
{ "==": [{ var: "plan" }, "business"] },
{ "!=": [{ var: "country" }, "SE"] }
]
}Schemaform supports the JSONLogic operators needed for v1 form behavior: var, and, or, !, equality and comparison operators, plus basic arithmetic. The expressions are persisted in the Form Definition, not embedded as executable JavaScript.
Effects
| Effect | When the condition is true | When the condition is false |
|---|---|---|
show | Target is visible | Target is hidden |
hide | Target is hidden | Target is visible |
enable | Target is enabled | Target is disabled |
disable | Target is disabled | Target is enabled |
require | Target Control is required | Target follows JSON Schema |
unrequire | Target Control is not required | Target follows JSON Schema |
show and hide change whether a Control, group, Columns node, Array node, or Page participates in the visible form. enable and disable change interactivity; disabled fields still keep their values. require and unrequire target Controls and override schema-level required behavior for the current render cycle.
When multiple rules target the same node, Core merges the directives. A target can be hidden and disabled at the same time, and require wins over unrequire if both are active for the same Control.
Evaluation order
The Renderer passes the current Working State to Core. Core evaluates Rules first, resolves the UI Schema into the current visible form, and then validation runs against the visible result.
That order is important: a required field hidden by a Rule must not block submit. Hidden values stay in Working State so the user can reveal a section and recover what they typed. The Submission Payload is a projection of Working State: hidden fields are stripped on submit unless the matching Control or Array uses retainWhenHidden.
{
type: "Control",
scope: "#/properties/internalCode",
retainWhenHidden: true,
}Use retainWhenHidden only when downstream systems genuinely need that hidden value. The default protects submissions from stale invisible data.
Live example
The example starts on the Starter plan. Change the plan to Business to show the company group and make company name required. Change the plan to Free to keep the company group hidden and disable VAT. If you typed company details earlier, they remain in Working State while hidden but are stripped from the Submission Payload.
Live form
Source
import type { FormDefinition } from "@mackehansson/schemaform";import { SchemaForm } from "@mackehansson/schemaform-shadcn";const checkoutForm = { formatVersion: 1, schema: { type: "object", required: ["plan", "email", "phone"], properties: { plan: { type: "string", title: "Plan", enum: ["free", "starter", "business"], }, email: { type: "string", title: "Email", format: "email", }, companyName: { type: "string", title: "Company name", minLength: 2, }, vat: { type: "string", title: "VAT number", }, phone: { type: "string", title: "Phone", }, }, }, uiSchema: { revealErrors: "onSubmit", pages: [ { type: "Page", title: "Checkout", children: [ { type: "Control", scope: "#/properties/plan", widget: "select" }, { type: "Control", scope: "#/properties/email" }, { type: "Group", id: "company-details", title: "Company details", children: [ { type: "Control", scope: "#/properties/companyName" }, ], }, { type: "Control", scope: "#/properties/vat" }, { type: "Control", scope: "#/properties/phone" }, ], }, ], }, rules: [ { effect: "show", condition: { "==": [{ var: "plan" }, "business"] }, target: "company-details", }, { effect: "require", condition: { "==": [{ var: "plan" }, "business"] }, target: "#/properties/companyName", }, { effect: "disable", condition: { "==": [{ var: "plan" }, "free"] }, target: "#/properties/vat", }, { effect: "unrequire", condition: { "==": [{ var: "plan" }, "business"] }, target: "#/properties/phone", }, ],} satisfies FormDefinition;export function App() { return ( <SchemaForm definition={checkoutForm} defaultValues={{ plan: "starter" }} onSubmit={(data) => console.log(data)} /> );}Modeling guide
Use JSON Schema for data shape and validation constraints that are always true. Use UI Schema for presentation: Pages, groups, layout, and Widgets. Use Rules when behavior depends on Working State.
| Question | Layer |
|---|---|
| Is this value always required for valid data? | JSON Schema |
| Should this Control render as a select? | UI Schema |
| Should this group appear only for Business? | Rules |
| Should a field become required only after another answer? | Rules |
| Should a hidden field still submit? | UI Schema retainWhenHidden |
That split keeps Rules focused: they adapt the current interaction, while JSON Schema remains the durable data contract and UI Schema remains the durable presentation tree.