Skip to main content

Command Palette

Search for a command to run...

Create Dynamic JSON Schema Forms in React

Updated
22 min readView as Markdown

TL;DR: Some forms outgrow components and need to be treated as data. Here's what these "schema-driven" forms are, and how to build them.

Most React developers start with the same assumption: forms are components. You write JSX for structure, use hooks for state management, and handle validation with libraries like Zod or Yup.

But what most devs don’t realize is that at some point, you actually outgrow this model.

In this article, I'll show you when this happens, why you might want to start treating form definitions as data instead of code, and walk through building such “schema-driven” forms with SurveyJS, an open-source forms library for React (and also for Angular and Vue3, among others) that implements this forms-as-data model.

What You’ll Learn

By the end of this article, you'll know:

  • The "forms as data" mental model and when it applies

  • How schema-driven form engines work internally

  • What logic moves into JSON vs. what stays in React

  • How to use SurveyJS to build production-ready schema-driven forms

  • When to use this approach--and when not to

Let’s get into it!

The Default Assumption: Forms Live in Components

Most React applications define forms inside components. This feels “natural”, because forms are rendered UI, after all, and React excels at composition.

Here's what this typically looks like:

  • Structure lives in JSX – <input>, <select>, <textarea> elements

  • Logic lives in hooks – useState for form data, useEffect for derived values

  • Validation happens via schemas or callbacks – Zod, Yup, or custom validation functions

This assumption holds true for most forms. But at a point, form definitions start behaving less like UI implementation details and more like data.

Section 1: When Do Form Definitions Start Acting Like Data?

Let me show you what I mean with a real example. Here's a multi-step form built the traditional way:

import { useState, useEffect } from "react";

const STEPS = ["details", "order", "account", "review"];

export function ComponentFormComparison() {
  const [formData, setFormData] = useState({
    firstName: "",
    email: "",
    hasAccount: "",
    username: "",
    password: "",
    price: 0,
    quantity: 1,
    taxRate: 0.1,
    total: 0,
    subtotal: 0,
    tax: 0,
    satisfaction: 0,
    positiveFeedback: "",
  });
  const [errors, setErrors] = useState({});
  const [currentStep, setCurrentStep] = useState(0);

  // Scattered derived value logic - different useEffects for different concerns
  useEffect(() => {
    const subtotal = Number(formData.price || 0) * Number(formData.quantity || 0);
    const tax = subtotal * (Number(formData.taxRate) || 0);
    setFormData((prev) => ({ ...prev, subtotal, tax, total: subtotal + tax }));
  }, [formData.price, formData.quantity, formData.taxRate]);

  const validate = () => {
    const next = {};
    if (!formData.firstName?.trim()) next.firstName = "Required";
    if (!formData.email?.trim()) next.email = "Required";
    else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
      next.email = "Invalid email";
    }
    if (formData.hasAccount === "Yes") {
      if (!formData.username?.trim())
        next.username = "Required when you have an account";
      if (!formData.password?.trim())
        next.password = "Required when you have an account";
      else if (formData.password?.length < 6)
        next.password = "Min 6 characters";
    }
    if (formData.price < 0) next.price = "Must be non-negative";
    if (formData.quantity < 1) next.quantity = "Must be at least 1";
    if (currentStep === 2 && formData.satisfaction === 0)
      next.satisfaction = "Please rate";
    if (formData.satisfaction >= 4 && !formData.positiveFeedback?.trim()) {
      next.positiveFeedback = "Please share what you liked";
    }
    setErrors(next);
    return Object.keys(next).length === 0;
  };

  // ... rendering logic with conditional JSX for each step
}

Notice what's happening here:

  1. Derived values scattered across useEffect hooks – Subtotal, tax, and total are calculated in one effect. If we need another derived field, we have to add another useEffect.

  2. Conditional validation buried in functions – Username is only required when hasAccount === "Yes". This rule lives in the validation function, separated from the field definition.

  3. Cross-field dependencies everywhere – The satisfaction rating controls whether we show a feedback field. The current step controls which validation rules apply.

  4. Multi-step state management – We're manually tracking currentStep and conditionally rendering different sections of the form.

So ask yourself: what happens when a business analyst needs to review these rules? They'd need to read JavaScript, understand React hooks, and trace logic across multiple functions.

When Do Forms Outgrow Components?

As you saw in the example above, your form definitions start acting like data when they include:

  • Explicit workflows – Multi-step processes with conditional navigation

  • Business rules – "Show field X when Y > 100 and Z is selected"

  • Derived values – Calculations that feed into other calculations or navigation logic

  • Conditional paths – Different field sets for different user types or scenarios

At that point, your form has a lifespan -- it evolves over months or years, stakeholders review it periodically, and the logic matters as much as the layout.

Keeping that logic inside components means every change is a code change, every review is a code review, and every bug is a React bug.

Section 2: The "Forms as Data" Mental Model

Here's the primary mental shift you need:

What if form structure wasn't implicit in components?

What if it was expressed explicitly as structured data?

So, instead of this:

useEffect(() => {
  const subtotal = formData.price * formData.quantity;
  const tax = subtotal * formData.taxRate;
  setFormData(prev => ({ ...prev, total: subtotal + tax }));
}, [formData.price, formData.quantity, formData.taxRate]);

You'd have this:

{
  "type": "expression",
  "name": "total",
  "expression": "{subtotal} + {tax}"
}

SurveyJS makes this possible – its engine watches these value dependencies automatically. When price, quantity, or taxRate changes, the expression is re-evaluated. No useEffect, or manual state updates needed.

A well-designed form schema is the complete source of truth for a form's behaviour. It can express not just what fields exist, but what type of question each one is -- a text input, a dropdown, a rating widget, a checkbox group.

  • It carries validation rules: which fields are required, what format an email must match, what numeric range is acceptable.

  • It describes conditional logic -- field X only appears when the answer to field Y meets some condition.

  • It can encode calculations, where one field's value is derived from others and updates automatically.

  • For multi-step flows, it owns the page ordering and can branch users down different paths depending on what they've entered.

That might seem a lot to pack into data -- but the payoff is in what it buys you on the component side.

React stops being the place where form logic lives and becomes a pure renderer. Instead of ad-hoc imperative code scattered across useEffect hooks and event handlers, a form engine reads the schema and handles state transitions, dependency tracking, and expression evaluation on your behalf.

React just renders whatever the engine tells it to.

Benefits of this approach:

  • When a business rule changes, you edit data, not code.

  • When you want to audit what a form does, you read a schema, not a component tree.

  • The logic is declarative and in one place, rather than procedural, and spread everywhere.

What changes architecturally:

Layer

Responsibility

Example

Schema

Structure, rules, dependencies

JSON definition of fields, visibleIf conditions, expression calculations

Form Engine

Evaluation, state transitions, expression resolution

SurveyJS evaluates {price} * {quantity} automatically

React

Rendering, layout, integration

<Survey model={survey} /> – that's it

Section 3: SurveyJS as a Schema-Driven Form Engine

For the rest of this article, I'll use SurveyJS as the concrete implementation of schema-driven forms in React. It's been around since 2015, is MIT-licensed, and gets the architecture right: the JSON schema is the single source of truth, and a built-in rules engine handles visibleIf conditions, expression fields, calculated values, and multi-page navigation -- no external libraries needed.

Here's how it compares to the other main options:

SurveyJS

React Hook Form

TanStack Form

Schema / JSON-driven

✅ First-class

❌ Code-first

❌ Code-first

Expression & conditional logic

✅ Built-in engine

⚠️ Manual, ad-hoc

⚠️ Manual, ad-hoc

Built-in validation

✅ Declarative in schema

⚠️ Resolver-based

✅ Built-in validators

Best for

Complex, rule-heavy forms

Simple to mid-complexity forms

Type-safe, headless forms

With SurveyJS, an additional benefit is that the same JSON definition works across React, Vue, and Angular. Plus there's no hosted backend required, so you stay in full control of where data goes.

Section 5: Defining a Form Entirely in JSON with SurveyJS

Let's build the same multi-step form we saw earlier, but this time as a schema. I'll show you the evolution from simple to complex.

Installation

> npm install survey-core survey-react-ui

That's it. No backend service, API keys, or external dependencies beyond the library itself.

Step 1: Simple Form, Built with a JSON Schema

Start with the basics: fields and validation.

export const simpleSchema = {
  title: "Contact Form",
  description: "A minimal schema: fields and validation only.",
  showQuestionNumbers: "off",
  pages: [{
    name: "basic",
    elements: [
      {
        type: "text",
        name: "firstName",
        title: "First Name",
        isRequired: true,
      },
      {
        type: "text",
        name: "email",
        title: "Email",
        inputType: "email",
        isRequired: true,
        validators: [
          { type: "email", text: "Please enter a valid email address." },
        ],
      },
      {
        type: "comment",
        name: "message",
        title: "Message",
        rows: 4,
      },
    ],
  }],
  completedHtml: "<p>Thank you for your message.</p>",
};

Validation is declared inline, field types are explicit, and SurveyJS even lets you provide custom HTML via completedHtml that renders after submission. No handler functions, validation logic, or state – just a description of what the form is.

Rendering it is equally minimal:

import { useState, useEffect, useCallback } from "react";
import { Model } from "survey-core";
import { Survey } from "survey-react-ui";
import "survey-core/defaultV2.min.css";

export function FormRenderer({ schema, onComplete, theme }) {
  const [survey] = useState(() => {
    const model = new Model(schema);
    if (theme) model.applyTheme(theme);
    return model;
  });

  const handleComplete = useCallback(
    (sender) => {
      const results = sender.data;
      if (onComplete) onComplete(results);
    },
    [onComplete]
  );

  useEffect(() => {
    survey.onComplete.add(handleComplete);
    return () => survey.onComplete.remove(handleComplete);
  }, [survey, handleComplete]);

  return (
    <div className="survey-container">
      <Survey model={survey} />
    </div>
  );
}

This component doesn't know anything about the form. It doesn't know how many fields there are, what validation rules apply, or what happens on submission. It receives a schema, builds a model, and hands it to <Survey />.

Try it yourself here: https://codepen.io/sixthextinction/pen/ogLRELg

That's the separation working as intended – React owns the rendering, SurveyJS owns everything else.

Step 2: Add Conditional Logic

Now let's add conditional visibility based on user input.

export const conditionalSchema = {
  title: "Feedback Survey",
  description: "Conditional branches: show questions based on previous answers.",
  showQuestionNumbers: "on",
  showProgressBar: "top",
  pages: [{
    name: "feedback",
    elements: [
      {
        type: "radiogroup",
        name: "hasAccount",
        title: "Do you have an account with us?",
        choices: ["Yes", "No"],
        isRequired: true,
      },
      {
        type: "text",
        name: "username",
        title: "Username",
        visibleIf: "{hasAccount} = 'Yes'", // ← Declarative dependency
        isRequired: true,
      },
      {
        type: "text",
        name: "password",
        title: "Password",
        inputType: "password",
        visibleIf: "{hasAccount} = 'Yes'", // ← Same condition
        isRequired: true,
      },
      {
        type: "text",
        name: "email",
        title: "Email (for new account)",
        inputType: "email",
        visibleIf: "{hasAccount} = 'No'", // ← Opposite condition
        isRequired: true,
      },
      {
        type: "rating",
        name: "satisfaction",
        title: "How satisfied are you?",
        rateMin: 1,
        rateMax: 5,
        minRateDescription: "Not at all",
        maxRateDescription: "Very satisfied",
      },
      {
        type: "comment",
        name: "positiveFeedback",
        title: "What did you like?",
        visibleIf: "{satisfaction} >= 4", // ← Numeric comparison
      },
      {
        type: "comment",
        name: "improvementFeedback",
        title: "What can we improve?",
        visibleIf: "{satisfaction} <= 2", // ← Another condition
      },
    ],
  }],
  completedHtml: "<p>Thank you for your feedback.</p>",
};

According to this schema, Field #2 changes depending on the answer to Field #1, and Field #4 (feedback message) will switch from “What can we improve?” to “What did you like?” depending on whether Field #3’s answer was below 3 or above 3, respectively.

Try it yourself here: https://codepen.io/sixthextinction/pen/VYjOQme

The key addition is visibleIf -- a declarative expression that tells the engine when a field should be visible. The SurveyJS engine watches the referenced fields and re-evaluates automatically when they change, without needing a {condition && <Field />} in JSX, a useState toggle, or an event handler.

Also, notice the expression syntax:

  • {hasAccount} = 'Yes' – String equality

  • {satisfaction} >= 4 – Numeric comparison

  • {mobileFeatures} notempty – Check if array/field has value

  • Curly braces {fieldName} reference other field values

SurveyJS uses its own expression language. Common operators: =, !=, >, <, >=, <=, contains, notempty, empty. Functions: iif(), sum(), avg(), min(), max(). See the full expression reference for details.

💡 Gotcha #1: Typos in visibleIf expressions fail silently. {hasAcount} instead of {hasAccount} won't throw, but the field will just never appear. Always write integration tests that exercise every conditional path.

💡 Gotcha #2:  For checkbox or multi-select fields, {features} = 'auth' won't work. String equality doesn't match against arrays, so use contains instead, like: {features} contains 'auth'.

Step 3: Add Derived Values

Now let's add calculations that update automatically based on user input.

export const calculationsSchema = {
  title: "Order Calculator",
  description: "Derived values: subtotal, tax, total. Volume discount when quantity > 10.",
  showQuestionNumbers: "off",
  pages: [{
    name: "order",
    elements: [
      {
        type: "text",
        name: "price",
        title: "Unit Price",
        inputType: "number",
        defaultValue: 0,
      },
      {
        type: "text",
        name: "quantity",
        title: "Quantity",
        inputType: "number",
        defaultValue: 1,
      },
      {
        type: "dropdown",
        name: "taxRate",
        title: "Tax Rate",
        choices: [
          { value: 0.05, text: "5%" },
          { value: 0.10, text: "10%" },
          { value: 0.15, text: "15%" },
        ],
      },
      {
        type: "expression", // Calculated field (visible in UI)
        name: "subtotal",
        title: "Subtotal (before discount)",
        expression: "iif({quantity} > 10, {price} * {quantity} * 0.9, {price} * {quantity})",
        displayStyle: "currency",
        currency: "USD",
        description: "10% volume discount when quantity > 10",
      },
      {
        type: "expression",
        name: "tax",
        title: "Tax",
        expression: "{subtotal} * {taxRate}",
        displayStyle: "currency",
        currency: "USD",
      },
      {
        type: "expression",
        name: "total",
        title: "Total",
        expression: "{subtotal} + {tax}",
        displayStyle: "currency",
        currency: "USD",
      },
    ],
  }],
  calculatedValues: [ // Calculated values (NOT visible in UI)
    {
      name: "discount",
      expression: "iif({total} > 100, {total} * 0.1, 0)",
      includeIntoResult: true, // Appears in submission data
    },
    {
      name: "finalTotal",
      expression: "{total} - {discount}",
      includeIntoResult: true,
    },
  ],
  completedHtml: "<p>Order summary saved. Check the JSON result for discount and finalTotal.</p>",
};

Try it yourself here: https://codepen.io/sixthextinction/pen/xbONYdK

There are two ways to declare derived values in SurveyJS.

  • Expression fields (type: "expression") are visible in the UI and update in real-time as the user types.

  • Calculated values (calculatedValues) are hidden -- they never render, but appear in the submission data if you set includeIntoResult: true.

Think of expression fields as output the user needs to see, and calculated values as output your backend needs to receive.

Expression Fields

Calculated Values

Visible in UI

✅ Yes

❌ No

In submission data

✅ Always

Only if includeIntoResult: true

Declaration

type: "expression" in elements

calculatedValues: [...] at schema root

Use case

Subtotal, tax, total

Discount amount, derived flags for backend

In the schema above, subtotal, tax, and total are expression fields -- the user watches them update as they change price or quantity. discount and finalTotal are calculated values -- they feed into the submission payload without cluttering the form.

The expressions themselves support conditional logic via iif(), and chain naturally -- tax depends on subtotal, total depends on both. Compare that to the component version, where each dependency requires a carefully maintained useEffect with the right array. Here, the engine figures out the dependency graph itself.

💡 Gotcha #3: Forgetting includeIntoResult: true for calculatedValues you need in submission data. By default, calculated values are excluded from results to keep the JSON clean.

The Expression Complexity Trade-off

In the next example (full demo), you'll see a featuresCost expression that's quite long. Let’s cover that first so you’re prepared:

expression: `iif({features} contains 'auth', 500, 0) + 
             iif({features} contains 'dashboard', 800, 0) + 
             iif({features} contains 'api', 600, 0) + ...`

It's verbose -- but intentionally so. Every feature's cost is explicit in the schema. A business analyst can open this file and say "Dashboard should be $900, not $800" without touching a React component. That's the trade-off: verbosity in exchange for auditability.

In production, you can recover the cleanliness without losing the declarative structure by registering custom functions:

import { FunctionFactory } from "survey-core";

FunctionFactory.Instance.register("calculateFeatureCost", function(params) {
  const features = params[0] || [];
  const pricing = { auth: 500, dashboard: 800, api: 600, admin: 1000, reporting: 400 };
  return features.reduce((sum, feature) => sum + (pricing[feature] || 0), 0);
});

Which lets your schema expression collapse to:

{
  "type": "expression",
  "name": "featuresCost",
  "expression": "calculateFeatureCost({features}) + calculateFeatureCost({mobileFeatures})",
  "displayStyle": "currency",
  "currency": "USD"
}

This gives you the best of both worlds. The pricing logic lives in one registered function, the schema stays readable, and a non-engineer can still audit the structure.

Step 4: Full Demo – Multi-Page with Complex Logic

Now let's combine everything: multi-page navigation, conditional visibility, and derived values feeding into navigation logic.

export const fullDemoSchema = {
  title: "Project Estimate Request",
  description: "Page-level visibleIf, calculations feeding navigation, business rules in JSON.",
  showProgressBar: "top",
  progressBarType: "pages",
  showQuestionNumbers: "on",
  pages: [
    {
      name: "project",
      elements: [
        {
          type: "text",
          name: "projectName",
          title: "Project Name",
          isRequired: true,
        },
        {
          type: "radiogroup",
          name: "projectType",
          title: "Project Type",
          choices: [
            { value: "web", text: "Web Application" },
            { value: "mobile", text: "Mobile App" },
            { value: "other", text: "Other" },
          ],
          isRequired: true,
        },
      ],
    },
    {
      name: "features",
      title: "Feature Selection",
      visibleIf: "{projectName} notempty", // ← Page-level condition
      elements: [
        {
          type: "checkbox",
          name: "features",
          title: "Required Features",
          choices: [
            { value: "auth", text: "Authentication ($500)" },
            { value: "dashboard", text: "Dashboard ($800)" },
            { value: "api", text: "API Integration ($600)" },
            { value: "admin", text: "Admin Panel ($1000)" },
            { value: "reporting", text: "Reporting ($400)" },
          ],
        },
        {
          type: "checkbox",
          name: "mobileFeatures",
          title: "Mobile-specific Features",
          visibleIf: "{projectType} = 'mobile'", // ← Field-level condition
          choices: [
            { value: "push", text: "Push Notifications ($300)" },
            { value: "offline", text: "Offline Support ($500)" },
          ],
        },
        {
          type: "comment",
          name: "additionalNotes",
          title: "Additional Notes",
          rows: 2,
        },
      ],
    },
    {
      name: "budget",
      title: "Budget Breakdown",
      visibleIf: "{projectName} notempty",
      elements: [
        {
          type: "expression",
          name: "featuresCost",
          title: "Features Cost (estimated)",
          expression: `iif({features} contains 'auth', 500, 0) + iif({features} contains 'dashboard', 800, 0) + iif({features} contains 'api', 600, 0) + iif({features} contains 'admin', 1000, 0) + iif({features} contains 'reporting', 400, 0) + iif({mobileFeatures} notempty and {mobileFeatures} contains 'push', 300, 0) + iif({mobileFeatures} notempty and {mobileFeatures} contains 'offline', 500, 0)`,
          displayStyle: "currency",
          currency: "USD",
          description: "Sum of selected feature prices",
        },
        {
          type: "expression",
          name: "baseCost",
          title: "Base Cost",
          expression: "iif({projectType} = 'web', 2000, iif({projectType} = 'mobile', 3000, 1500))",
          displayStyle: "currency",
          currency: "USD",
        },
        {
          type: "expression",
          name: "estimatedTotal",
          title: "Estimated Total",
          expression: "{baseCost} + {featuresCost}",
          displayStyle: "currency",
          currency: "USD",
        },
        {
          type: "text",
          name: "budgetLimit",
          title: "Your Budget (USD)",
          inputType: "number",
          validators: [{ type: "numeric", minValue: 0 }],
        },
      ],
    },
    {
      name: "contact",
      title: "Contact Info",
      visibleIf: "{estimatedTotal} >= 1000", // ← Navigation based on calculation
      elements: [
        {
          type: "text",
          name: "contactName",
          title: "Contact Name",
          isRequired: true,
        },
        {
          type: "text",
          name: "contactEmail",
          title: "Email",
          inputType: "email",
          isRequired: true,
          validators: [{ type: "email" }],

        },
        {
          type: "radiogroup",
          name: "urgency",
          title: "Urgency",
          choices: ["Low", "Medium", "High"],

        },
        {
          type: "comment",
          name: "followUp",
          title: "Preferred follow-up method",
          visibleIf: "{urgency} = 'High'", // ← Nested condition
        },
      ],
    },
  ],
  completedHtml: "<h3>Thank you!</h3><p>We'll review your estimate request and get back to you.</p>",
};

Try it yourself here: https://codepen.io/sixthextinction/pen/EayzQmB

A few things worth pointing out in this schema.

  • Page visibility works the same way field visibility does -- the contact page carries visibleIf: "{estimatedTotal} >= 1000", so it only appears once the budget calculation crosses that threshold.

  • `The calculation itself is an expression field on the previous page, which means a derived value is directly controlling navigation. That's not something you can do cleanly with useEffect.

  • The Mobile-specific features checkbox only appears when projectType = 'mobile' in the previous page -- a field-level condition nested inside a page that has its own page-level condition!

  • And the followUp field on the contact page only appears when urgency is set to High. Conditions compose naturally, at any level of the hierarchy.

The entire workflow -- conditional navigation, cost calculation, branching field sets -- is defined in one JSON object. We don’t need React Router logic for conditional page flow, useEffect chains to keep derived values in sync, or scattered state management across components.

If a business rule changes, just edit the schema.

Bonus: Adding Event Hooks for Debugging

SurveyJS exposes ~80 events you can tap into (like onComplete, onValueChanged, onCurrentPageChanged, etc.) to see the engine working in real time, or use for further processing:

export function FormRenderer({ schema, onComplete }) {
  const [survey] = useState(() => new Model(schema));
  const [eventLog, setEventLog] = useState([]);

  const log = useCallback((msg) => {
    setEventLog((prev) => [...prev.slice(-9), `[\({new Date().toLocaleTimeString()}] \){msg}`]);
  }, []);

  // Handle value changes
  useEffect(() => {
    const onValueChanged = (sender, options) => {
      log(`onValueChanged: \({options.name} → \){JSON.stringify(options.value)}`);
    };
    survey.onValueChanged.add(onValueChanged);
    return () => survey.onValueChanged.remove(onValueChanged);
  }, [survey, log]);

  // Handle page navigation
  useEffect(() => {
    const onCurrentPageChanged = (sender, options) => {
      const pageName = options.newCurrentPage?.name ?? "unknown";
      log(`onCurrentPageChanged → ${pageName}`);
    };
    survey.onCurrentPageChanged.add(onCurrentPageChanged);
    return () => survey.onCurrentPageChanged.remove(onCurrentPageChanged);
  }, [survey, log]);

  // Handle completion
  const handleComplete = useCallback(
    (sender) => {
      const results = sender.data;
      if (onComplete) onComplete(results);
      log("onComplete - results: " + JSON.stringify(Object.keys(results)));
    },
    [onComplete, log]
  );

  useEffect(() => {
    survey.onComplete.add(handleComplete);
    return () => survey.onComplete.remove(handleComplete);
  }, [survey, handleComplete]);

  return (
    <div className="survey-container">
      <Survey model={survey} />
      <details style={{ marginTop: "1rem", fontSize: 12 }}>
        <summary>Event log (onValueChanged, onCurrentPageChanged, onComplete)</summary>
        <pre style={{ background: "#f5f5f5", padding: "0.5rem", maxHeight: 150, overflow: "auto" }}>
          {eventLog.length ? eventLog.join("\n") : "(events will appear here)"}
        </pre>
      </details>
    </div>
  );
}

React is listening to the engine, not controlling it. The SurveyJS engine evaluates the schema, manages state transitions, and tells React what to render.

💡 Debugging tip: If a field isn't appearing when you expect, onValueChanged logging will show you the engine's current state. Common culprits: typos in field names ({hasAcount} vs {hasAccount}), using = instead of == in comparisons, missing notempty checks on arrays, and case sensitivity mismatches ('yes' vs 'Yes').

What React Does vs. What SurveyJS Does

Concern

Handled By

Example

Which fields are visible?

Engine

Evaluates visibleIf conditions

What's the current field value?

Engine

Tracks form state in survey.data

What's the calculated total?

Engine

Evaluates expression fields

Should we navigate to the next page?

Engine

Checks page-level visibleIf

Rendering the UI

React

<Survey model={survey} />

Handling submission

React

onComplete callback

Custom styling

React

Themes, CSS, custom widgets

React's job is simple: render what the engine tells you to render, and handle the result when the user submits.

Living with Schema-Driven Forms

The value of schema-driven forms isn't that they replace component-defined forms. It's that they acknowledge a truth most developers eventually discover: some forms are best understood as rule systems that happen to render as UI.

If your forms:

  • Are rule-heavy -- cross-field dependencies, conditional navigation, derived values feeding other derived values.

  • Have business rules that evolve over time without structural rewrites.

  • Have people owning the logic (analysts, product managers) who aren't the same people who implement it.

  • The same form definition needs to run across multiple frameworks (React, Angular, Vue3, Vanilla JS etc.)

...then treating that form as a component is fighting the wrong battle. This structure wants to be data. Let it be data.

Your day-to-day work will change for the better.

  • Debugging shifts from tracing useEffect chains to inspecting schema rules and engine state -- onValueChanged logging shows you exactly what the engine sees.

  • Reasoning about dependencies becomes easier because visibleIf conditions are explicit and co-located with the fields they control, rather than scattered across JSX.

  • Code review improves too: a one-line JSON diff that changes ">= 500" to ">= 1000" communicates a business rule change clearly. A React refactor achieving the same thing might touch a dozen lines with no obvious intent.

But there are trade-offs to this approach.

  • No compile-time guarantees. TypeScript can't catch typos in visibleIf expressions -- {hasAcount} fails silently at runtime. Integration tests that exercise every conditional path are a must.

  • JSON is verbose. Six lines to express {subtotal} + {tax} is more typing than <div>Total: ${subtotal + tax}</div>. For complex forms the clarity is worth it; for simple ones…not so much.

  • Scale requires discipline. A 500-line schema with 20 calculated values gets hard to navigate fast. Split large schemas into modules, establish naming conventions, and treat the schema as a first-class artifact with its own documentation.

  • The expression language has a learning curve. iif() syntax isn't JavaScript, and it doesn't have the same tooling support. For complex logic, custom functions let you keep expressions readable while the heavy lifting happens in real JS.

Cheatsheet: When To Use JSON Schema Driven Forms?

Schema-Driven

Component-Driven

Fields

10+

Under 10

Pages

Multi-step with conditional navigation

Single page or simple linear flow

Conditional logic

10+ cross-field dependencies

Fewer than 3 conditions

Business rules

Change frequently, need audit trail

Stable, owned by developers

Stakeholders

Non-developers can easily review form logic

Developer-only concern

UI complexity

Standard inputs

Heavy custom UI, drag-drop, custom animations

Cross-platform

Need the same form across multiple frameworks/platforms.

Single environment