Create Dynamic JSON Schema Forms in React
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>elementsLogic lives in hooks –
useStatefor form data,useEffectfor derived valuesValidation 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:
Derived values scattered across
useEffecthooks – Subtotal, tax, and total are calculated in one effect. If we need another derived field, we have to add anotheruseEffect.Conditional validation buried in functions – Username is only required when
hasAccount === "Yes". This rule lives in the validation function, separated from the field definition.Cross-field dependencies everywhere – The satisfaction rating controls whether we show a feedback field. The current step controls which validation rules apply.
Multi-step state management – We're manually tracking
currentStepand 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, |
Form Engine | Evaluation, state transitions, expression resolution | SurveyJS evaluates |
React | Rendering, layout, integration |
|
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 valueCurly 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 |
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 setincludeIntoResult: 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 |
Declaration |
|
|
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 |
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
followUpfield 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, |
What React Does vs. What SurveyJS Does
Concern | Handled By | Example |
|---|---|---|
Which fields are visible? | Engine | Evaluates |
What's the current field value? | Engine | Tracks form state in |
What's the calculated total? | Engine | Evaluates expression fields |
Should we navigate to the next page? | Engine | Checks page-level |
Rendering the UI | React |
|
Handling submission | React |
|
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
useEffectchains to inspecting schema rules and engine state --onValueChangedlogging shows you exactly what the engine sees.Reasoning about dependencies becomes easier because
visibleIfconditions 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
visibleIfexpressions --{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 |
