@mackehansson/schemaform

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

EffectWhen the condition is trueWhen the condition is false
showTarget is visibleTarget is hidden
hideTarget is hiddenTarget is visible
enableTarget is enabledTarget is disabled
disableTarget is disabledTarget is enabled
requireTarget Control is requiredTarget follows JSON Schema
unrequireTarget Control is not requiredTarget 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

Checkout

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.

QuestionLayer
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.

On this page