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. A schema-based approach to form building may be appropriate for certain use cases, it 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.

Modern form libraries help manage state, field registration, and validation — but conditional rendering and business logic often remain imperative, brittle, and hard to test.

Existing Solutions

Schema-based form libraries like react-jsonschema-form attempt to solve this by defining forms declaratively. These libraries generate forms 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 dependencies, 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 schema works — it’s strict, testable, and decouples logic from rendering. But as conditional branches grow, so does nesting. Readability suffers. And once you need to introduce side effects (e.g. fetching dropdown options), the model starts to break down.

A Schema-Based Approach to Dynamic Forms

To tackle these issues, I built a form system inspired by schema-driven design but focused on readability and maintainability. Also less of a learning curve since the declaration will simply be a flat JavaScript object.

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 the schema. The rendering engine simply consumes this schema and renders accordingly.

Declarative Field Definitions

Each field is defined with properties like:

  • label: The display name

  • component: The React component to render

  • required: Boolean or function

  • renderWhen: A function to determine visibility

  • disableWhen, requiredWhen: For dynamic state

These functions receive the full form state, enabling reactive, field-aware behavior — all while keeping the logic in one place.

Schema-Driven Rendering

At runtime, the renderer iterates over the schema and evaluates conditions (e.g. renderWhen). It renders only the fields that should appear, in the correct order and state.

Field behavior becomes predictable. Want to reorder fields? Just change the object keys. Want to hide a field based on another’s value? Just update the renderWhen.

The component field is flexible — it can be anything from a plain input to a complex field with side effects.

Handling Imperative Changes

Some UI updates can’t be expressed through form state alone — e.g. disabling a field after a user clicks “Lookup”. These side effects aren’t captured by pure render logic.

To support this, we introduce an imperative layer alongside the schema. This layer stores transient UI state (e.g. manually disabled fields). The renderer respects both the schema and this layer, combining declarative intent with imperative overrides.

This separation keeps the schema clean, while still supporting interactive behaviors.

Real-World Use Case: A Data Cataloging Platform

This solution was implemented in a data cataloging platform, where products can be registered with various storage structures and connection details. Requirements included:

  • Fields shown/hidden based on storage type

  • Dropdown options fetched dynamically

  • Fields pre-filled from external data

  • Integration with existing design systems and form libraries

Using a schema-based form engine made this complexity manageable. Logic lived in one place, rendering was predictable, and updates were straightforward.

Conclusion

A schema-driven approach to form building can dramatically reduce form complexity and make your codebase more maintainable. Benefits include:

  • Cleaner, more readable forms

  • Clear separation between business logic and rendering

  • Simpler updates when requirements change

  • Built-in support for dynamic field behavior

This model scales well — especially in environments where forms are complex, user-driven, and subject to change.

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