Skip to main content
TL;DR: Use FormEngine’s Wizard component for step-by-step approval flows, renderWhen for role-based field visibility, and setValues actions for routing decisions. Each approval stage is a Wizard step; the JSON schema drives what each approver sees without a code deploy when the process changes. Last reviewed: April 2026.
FormEngine Designer — build workflow approval forms visually

The problem

Enterprise approval workflows have two recurring pain points:
  1. Form structure changes with the process. A vendor onboarding form that worked for 20 vendors now needs a compliance section for vendors above $50K spend. Adding that section normally means a code change, PR review, and a deployment window.
  2. Different approvers see different fields. The requester fills in the request details. The manager approves or rejects. Finance adds cost-center codes. Legal flags contractual risk. Each role sees a different slice of the same underlying form — and that slice changes as the process evolves.
FormEngine solves both by keeping the form definition in a JSON schema stored in a database. Change the schema, and the next user to open the form gets the updated version — no deployment.

Schema design for approval stages

Each approval stage maps to a Wizard step. The Wizard’s per-step validation ensures fields are complete before advancing.
{
  "type": "Wizard",
  "name": "approvalWizard",
  "props": {
    "validateOnNext": true,
    "validateOnFinish": true
  },
  "components": [
    {
      "type": "WizardStep",
      "name": "requestStep",
      "props": { "label": "Request Details" },
      "components": [
        {
          "type": "Input",
          "name": "vendorName",
          "props": { "label": "Vendor Name", "required": true }
        },
        {
          "type": "NumberFormat",
          "name": "requestedAmount",
          "props": { "label": "Requested Amount (USD)", "required": true }
        },
        {
          "type": "Textarea",
          "name": "justification",
          "props": { "label": "Business Justification", "required": true }
        }
      ]
    },
    {
      "type": "WizardStep",
      "name": "managerStep",
      "props": { "label": "Manager Review" },
      "components": [
        {
          "type": "Radio",
          "name": "managerDecision",
          "props": {
            "label": "Decision",
            "required": true,
            "options": [
              { "label": "Approve", "value": "approved" },
              { "label": "Reject", "value": "rejected" },
              { "label": "Escalate to Director", "value": "escalated" }
            ]
          }
        },
        {
          "type": "Textarea",
          "name": "managerNotes",
          "props": { "label": "Manager Notes" },
          "renderWhen": "form.data.managerDecision === 'rejected'"
        }
      ]
    },
    {
      "type": "WizardStep",
      "name": "financeStep",
      "props": { "label": "Finance Coding" },
      "renderWhen": "form.data.managerDecision === 'approved' || form.data.managerDecision === 'escalated'",
      "components": [
        {
          "type": "Input",
          "name": "costCenter",
          "props": { "label": "Cost Center Code", "required": true }
        },
        {
          "type": "Input",
          "name": "glAccount",
          "props": { "label": "GL Account", "required": true }
        }
      ]
    }
  ]
}
Key patterns here:
  • validateOnNext: true prevents advancing with incomplete fields
  • renderWhen on the Finance step hides it entirely when the manager rejects — Finance never sees the step
  • renderWhen on Manager Notes makes the rejection notes field appear only when “Reject” is selected

Role-based field visibility

Each approver role sees a different subset of fields. The cleanest approach: embed the current user’s role into initialData when loading the form, then use renderWhen to control visibility per role.
// Load schema from DB, inject role into initial data
const schema = await db.forms.findOne({ id: formId })
const currentRole = session.user.role // 'requester' | 'manager' | 'finance' | 'legal'

return (
  <FormViewer
    view={view}
    form={JSON.stringify(schema)}
    initialData={{ currentRole, ...existingFormData }}
    onActionEventAsync={async (event) => {
      if (event.name === 'submit') {
        await db.approvals.update({ id: formId, data: event.args.formData })
      }
    }}
  />
)
In the schema, fields visible only to Finance:
{
  "type": "Input",
  "name": "costCenter",
  "props": { "label": "Cost Center", "required": true },
  "renderWhen": "form.data.currentRole === 'finance'"
}
The currentRole value lives in form.data alongside the actual form fields. It’s injected once on load and never submitted (filter it out in your onActionEventAsync handler before saving to the database).

Conditional routing with setValues

When a manager escalates, you often need to auto-populate routing metadata — who the next approver is, which queue it goes to. Use setValues actions triggered by the radio button’s onChange event:
{
  "type": "Radio",
  "name": "managerDecision",
  "props": {
    "label": "Decision",
    "options": [
      { "label": "Approve", "value": "approved" },
      { "label": "Reject", "value": "rejected" },
      { "label": "Escalate to Director", "value": "escalated" }
    ]
  },
  "actions": [
    {
      "event": "onChange",
      "action": "setValues",
      "args": {
        "values": {
          "nextQueue": {
            "computeType": "function",
            "value": "return form.data.managerDecision === 'escalated' ? 'director-queue' : form.data.managerDecision === 'approved' ? 'finance-queue' : 'closed'"
          }
        }
      }
    }
  ]
}
nextQueue is now a hidden metadata field the backend reads to route the record to the right approval queue — set automatically as the manager makes their decision.

Escalation threshold: conditional compliance step

For requests above a spend threshold, a compliance review step becomes required. Add the step with a renderWhen that checks the amount:
{
  "type": "WizardStep",
  "name": "complianceStep",
  "props": { "label": "Compliance Review" },
  "renderWhen": "form.data.requestedAmount >= 50000",
  "components": [
    {
      "type": "Checkbox",
      "name": "vendorScreeningComplete",
      "props": { "label": "Vendor screening completed", "required": true }
    },
    {
      "type": "Checkbox",
      "name": "sanctionsCheckComplete",
      "props": { "label": "Sanctions check completed", "required": true }
    },
    {
      "type": "Uploader",
      "name": "complianceEvidence",
      "props": { "label": "Attach compliance evidence" }
    }
  ]
}
When requestedAmount is below $50,000, this step never appears and its fields are skipped in validation. When amount crosses the threshold (for example, if the requester updates the amount field), the step appears automatically without any code change.

Storing and versioning schemas

Approval processes evolve. A schema versioning approach prevents in-flight approvals from breaking when the schema changes:
// When a new approval starts, snapshot the current schema version
await db.approvals.create({
  id: newApprovalId,
  schemaVersion: await db.forms.getCurrentVersion(formId),
  schemaSnapshot: currentSchema, // store the exact schema used
  status: 'pending',
  data: {}
})

// When loading an in-flight approval, use the snapshotted schema
const approval = await db.approvals.findOne({ id: approvalId })
const schema = approval.schemaSnapshot // exact schema at submission time
This means in-flight approvals continue to render with the schema that was current when they were submitted, while new approvals pick up the latest version.

Submit handler: multi-stage persistence

Each Wizard step’s “Next” action runs validation locally. The final “Submit” action sends the complete payload:
<FormViewer
  view={view}
  form={JSON.stringify(schema)}
  initialData={existingData}
  onActionEventAsync={async (event) => {
    if (event.name === 'submit') {
      const { currentRole, ...formData } = event.args.formData
      // Strip injected metadata; persist only real form fields
      await fetch(`/api/approvals/${approvalId}`, {
        method: 'PATCH',
        body: JSON.stringify({ data: formData, status: formData.nextQueue ?? 'pending' }),
        headers: { 'Content-Type': 'application/json' }
      })
    }
  }}
/>

Letting process owners change the form

With FormEngine Designer, you can embed the form editor in an admin panel so the process owner — not a developer — can add fields, change options, or adjust approval thresholds. The schema they save is exactly what FormViewer reads.
// Admin panel: process owner edits the approval form schema
<FormBuilder
  view={view}
  initialForm={currentSchema}
  onSave={async (updatedSchema) => {
    await db.forms.update({ id: formId, schema: updatedSchema, version: version + 1 })
  }}
/>
No PR, no deployment. The next approval cycle uses the updated form.

FormEngine on GitHub · npm · Pricing
Last modified on April 16, 2026