Skip to content

Config-Driven UI, With Behavior

Config-driven UI keeps coming back because it solves a real problem. “Here’s a generic component; drive it with a config object.” You have five forms that are 90% the same and you want them to stay 90% the same forever. A config object is how you keep the component from drifting into five separate code paths, and it genuinely works for that.

The ceiling shows up when behavior varies across forms — conditional required fields, cross-field constraints, cascading resets. At that point, the config needs a way to express those rules, and the options in most libraries mean reaching for something the JSON can no longer carry on its own. Umpire’s answer is to keep that layer declarative too: behavior as data.

The shape is nearly identical across every take. A fields array. A generic renderer that maps each entry to an input. Maybe a visibleWhen or dependencies block for simple conditionality. Representative prior art:

  • Ketan Khairnar — Configuration-Driven React Components — the post that prompted this page. A clear walk through the pattern and an honest read on where it reaches its limits.
  • react-jsonschema-form — the canonical config-driven form library. dependencies and ui:widget carry you far; this is the library most teams have met first.
  • Formily — schema-driven forms with explicit reactions. Worth studying for how it models the reactive layer that behavior eventually demands.
  • Kent C. Dodds — Inversion of Control — foundational piece on component design and where the config boundary should sit.

Three situations tend to be where the declarative vibe cracks:

  • Conditional requireds. state only matters when country === 'US'. The config needs a way to express that without the renderer growing a special case per form.
  • Cross-field validation. endDate must come after startDate. The predicate isn’t local to either field.
  • Cascading resets. Platform flips from Intel to AMD. Motherboard stale. RAM stale. Case stale. One field changed; three had to fall.

Each one needs a rule that references other field values. The usual path is to reach for a function in the config — a visibleWhen: (state) => … or a validate: (value, form) => …. That works at runtime, but it means the config is no longer portable: it can’t round-trip through JSON, it can’t be authored outside engineering, and it can’t cross a runtime boundary.

Umpire declares behavior as data. Rules are a graph. The component asks Umpire “is this field in play? required? foul?” and doesn’t need to know why.

  1. Rules are data. requires, enabledWhen, fairWhen, disables, oneOf, eitherOf, anyOf — all declarative. Predicates over field values and conditions, expressed without closures over component state.
  2. The renderer stays generic. One fields.map() loop reads enabled, required, fair, and reason off the availability map. No per-field branches. No if (field === 'state').
  3. @umpire/json round-trips. Rules, fields, conditions, and portable validators all serialize. What can’t serialize lands in excluded instead of disappearing silently.
  4. Framework-agnostic. Same schema. React, Solid, signals, Vue through stores — the behavior layer doesn’t care.

Config-driven UI’s original promise was one generic renderer, many forms. Umpire’s job is to keep that promise intact when the forms start having opinions about each other.

A typical config-driven config captures labels and placeholders:

{
"fields": [
{ "key": "email", "label": "Email", "type": "email" },
{ "key": "companyName", "label": "Company", "type": "text" },
{ "key": "state", "label": "State", "type": "text" }
]
}

When behavior shows up, most libraries grow a function slot — visibleWhen: (state) => … — and the config is no longer portable. The Umpire-augmented version adds rules and validators that stay serializable:

{
"version": 1,
"fields": {
"accountType": { "required": true, "isEmpty": "string" },
"email": { "required": true, "isEmpty": "string" },
"companyName": { "required": true, "isEmpty": "string" },
"state": { "required": true, "isEmpty": "string" }
},
"rules": [
{ "type": "enabledWhen", "field": "companyName",
"when": { "op": "eq", "field": "accountType", "value": "business" },
"reason": "Business accounts only" },
{ "type": "enabledWhen", "field": "state",
"when": { "op": "eq", "field": "country", "value": "US" },
"reason": "US addresses only" }
],
"validators": {
"email": { "op": "email", "error": "Enter a valid email address" }
}
}

No closures. No escape hatches. The right-hand side stays data all the way down.

Below: a textarea on the left, the live form on the right, and Umpire’s call on the bottom. The component code is the same component code regardless of what you type. Start with the seed, then press the try this buttons — each one mutates the JSON and the form responds.

Try this
Portable schema

schema.json

v1 · editable
Rendered form

generic.tsx

0/3 required
Account type*required
Email*required
Company nameout
Business accounts only
Tax IDout
Business accounts only
Country*required
Stateout
US addresses only
Umpire’s calllive derivation of the current schema
  • accountTypein playin play · required
  • emailin playin play · required
  • companyNameoutBusiness accounts only
  • taxIdoutBusiness accounts only
  • countryin playin play · required
  • stateoutUS addresses only

The three prompts each demonstrate one claim:

  • Add a rule — a new billingEmail field and an enabledWhen rule tying it to accountType === 'business'. The renderer picks up the field without any component edits or new case statements.
  • Swap a validator — the portable email validator becomes a matches regex. Email rejects anything outside @umpire.co. The field’s value carries through; only the verdict changes.
  • Break it — an unknown expression op (eqIgnoreCase) goes in. validateSchema() rejects the schema before any umpire is built. The form panel switches to the error state, and the textarea shows you exactly what it found.
import { umpire } from '@umpire/core'
import { fromJson } from '@umpire/json'
const { fields, rules, validators } = fromJson(schema)
const ump = umpire({ fields, rules, validators })

fromJson() calls validateSchema() first; if the schema is malformed or references unknown ops, it throws before any rules compile. The resulting ump is the same shape you’d get from hand-written TypeScript — a generic renderer doesn’t need to know the rules were authored in JSON.

{fieldOrder.map((field) => {
const meta = metaFor(field) // label, placeholder — renderer concern
const av = availability[field] // enabled, required, valid, reason, error
return (
<Field key={field} label={meta.label} required={av.required} disabled={!av.enabled}>
<Input
{...meta}
disabled={!av.enabled}
onChange={(value) => setField(field, value)}
/>
{!av.enabled && <Reason>{av.reason}</Reason>}
{av.valid === false && <Error>{av.error}</Error>}
</Field>
)
})}

Labels, placeholders, and types stay on the renderer side. The rest — “should this be in play? required? valid?” — comes from Umpire. Rules can change without the component being aware.

  • Umpire doesn’t render. You still own the field → input map, the layout, the theming, and the copy.
  • Arbitrary predicates don’t serialize. Stay inside namedValidators.* and the portable op set when the config is authored outside engineering. Custom functions still work at runtime — they land in excluded on the way to JSON.
  • play() recommends; you decide. Stale values after a condition change get flagged as fouls with suggested resets, but the form state is still yours to update.
  • A single form with two fields and a checkbox. Ship a hand-rolled component.
  • A one-off admin page you’ll delete in a quarter.
  • A single renderer with no interdependent fields anywhere. visibleWhen: true would do the job.

Config-driven plus Umpire pays off when you have three or more similar surfaces, non-engineers authoring configs, or behavior that needs to survive a runtime boundary (CMS-authored forms, server-rendered previews, a native port). That’s where rules as data starts earning its keep.

  • Quick Start — ten-minute walk through the rule primitives.
  • @umpire/json — the portable schema in full, including excluded and conditions.
  • @umpire/dsl — the pure expr.* vocabulary and compiler helpers.
  • Builders & Checks — JSON-specific expr.check(), portable validators, and rule builders.
  • Availability vs Validation — the mental model behind enabled, required, fair.
  • PC Builder — a cascade-heavy example in the same declarative spirit.