Post

Schema-driven Form Desgin

Schema-driven forms

Schema-driven Form Desgin

Building dynamic forms is a commmon but often painful exprience for developers. As forms grow in complexity - with validations, error states, conditional logic, and ever-changing requirements — the code can quickly become cluttered and difficult to maintain. In this post, I’ll share a schema-based approach to form building that separates business logic from rendering, simplifies conditional rendering, and improves maintainability.

## The Problem with Traditional Form Development

There are a few recurring pain points when developing forms in modern applications:

  • Visual Noise: Validation logic, error messages, and helper info often overwhelm the core rendering logic. It becomes difficult to see what’s actually being rendered.

  • Changing Requirements: When requirements change (as they always do), developers must sift through deeply nested code across multiple components, making updates tedious and error-prone.

  • Conditional Fields: The classic trap—nesting logic for conditionally rendered fields. When one field depends on the value of another, things get convoluted quickly.

Even with modern form state management libraries that handle field registration, validation, and touched states, handling dynamic field visibility or conditions usually ends up being imperative and messy. State management may be simplified, however business logic is an orthogonal issue.

Existing Solutions

Schema-based form libraries like react-jsonschema-form attempt to solve this by defining forms declaratively. These libraries generate form UIs from a schema and reduce a lot of boilerplate code.

React-jsonschema-form is a mature library that covers the grounds, however I do think there are issues when it comes to conditional fiels specifically. Nested conditions can be achieved via deependencies, consider an example with choosing the right vehicle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
{
  "title": "Vehicle Details",
  "type": "object",
  "properties": {
    "vehicleType": {
      "type": "string",
      "enum": ["Car", "Bike"],
      "title": "Select a vehicle"
    }
  },
  "dependencies": {
    "vehicleType": {
      "oneOf": [
        {
          "properties": {
            "vehicleType": { "const": "Car" },
            "fuelType": {
              "type": "string",
              "enum": ["Electric", "Petrol"],
              "title": "Fuel Type"
            }
          },
          "required": ["fuelType"],
          "dependencies": {
            "fuelType": {
              "oneOf": [
                {
                  "properties": {
                    "vehicleType": { "const": "Car" },
                    "fuelType": { "const": "Electric" },
                    "batteryCapacity": {
                      "type": "number",
                      "title": "Battery Capacity (kWh)"
                    }
                  },
                  "required": ["batteryCapacity"]
                },
                {
                  "properties": {
                    "vehicleType": { "const": "Car" },
                    "fuelType": { "const": "Petrol" },
                    "engineSize": {
                      "type": "number",
                      "title": "Engine Size (litres)"
                    }
                  },
                  "required": ["engineSize"]
                }
              ]
            }
          }
        },
        {
          "properties": {
            "vehicleType": { "const": "Bike" },
            "motorized": {
              "type": "boolean",
              "title": "Is it motorized?"
            }
          },
          "required": ["motorized"],
          "dependencies": {
            "motorized": {
              "oneOf": [
                {
                  "properties": {
                    "vehicleType": { "const": "Bike" },
                    "motorized": { "const": true },
                    "motorPower": {
                      "type": "number",
                      "title": "Motor Power (W)"
                    }
                  },
                  "required": ["motorPower"]
                },
                {
                  "properties": {
                    "vehicleType": { "const": "Bike" },
                    "motorized": { "const": false }
                  }
                }
              ]
            }
          }
        }
      ]
    }
  }
}

This definitely works and is strict and safe, and while the business logic is separated into a schema and sepearte from the rendering process, the readability is lacking.

A Schema-Based Approach to Dynamic Forms

To tackle these issues, I built a form system inspired by schema-driven design but enhanced for better readability and maintainability.

  1. Separate Business Logic from Rendering

The core principle is separation of concerns. Business logic—such as which fields are required or when a field should be visible—should live in a schema. The rendering logic simply consumes this schema and renders accordingly.

  1. Define Fields Declaratively

Each field is defined as an entry in the schema, where you can specify properties like:

label

type

required

renderWhen (a function that determines if the field should appear)

disableWhen / requiredWhen (functions that determine other dynamic behaviors)

These functions have access to the entire form state, enabling reactive behavior based on other fields.

  1. Schema-Driven Rendering At runtime, the renderer loops through the schema, executes the functions (e.g., renderWhen), and renders only the applicable fields in the specified order. This makes field order predictable and editing the form structure as simple as reordering keys in the schema.

  2. Schema Updates for Imperative Changes In some cases, you need to update fields imperatively—say, after a lookup action completes, you want to disable a field. Rather than mutate the UI directly, the solution is to update the schema itself. For instance, you might set disableWhen to always return true for that field, then re-render the form with the updated schema.

Real-World Use Case: A Data Cataloging Platform This solution was implemented in a data cataloging platform, where products needed to register with various storage structures and connection details. The form requirements were complex:

Conditional fields based on storage type

Data fetching to populate dropdowns

Field pre-filling based on existing data

Integration with existing UI libraries and form state managers

A schema-based form system handled this complexity elegantly. Fields were grouped logically (e.g., basic details, authentication, database), and each group could have its own renderWhen logic. Nested fields (like database engine details) were defined cleanly under parent groupings.

Handling Grouped Fields and Hierarchical Logic Grouping fields improves clarity and control. For example, a form could be structured into sections:

Basic Info: Name, description

Authentication: Auth provider, endpoint

Database: Port, engine, endpoint

Even within groups, fields retain their own logic. The group itself can have a renderWhen condition—if it returns false, the entire section is skipped. The field-level logic still applies, resulting in an AND relationship between group and field visibility.

Conclusion By using a schema-driven approach to form creation, we gain several benefits:

Cleaner, more readable code

Separation of business and rendering logic

Easier updates when requirements change

Built-in support for dynamic and reactive behavior

Declarative grouping and nested logic without clutter

This design scales well in large applications and makes form development significantly more maintainable. If you’re building complex forms and tired of wrestling with deeply nested components and imperative hacks, this might be the approach worth trying.

This post is licensed under CC BY 4.0 by the author.