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 pattern you’ve seen
Section titled “The pattern you’ve seen”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.
dependenciesandui:widgetcarry 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.
Where it breaks
Section titled “Where it breaks”Three situations tend to be where the declarative vibe cracks:
- Conditional requireds.
stateonly matters whencountry === 'US'. The config needs a way to express that without the renderer growing a special case per form. - Cross-field validation.
endDatemust come afterstartDate. 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.
The missing layer
Section titled “The missing layer”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.
- Rules are data.
requires,enabledWhen,fairWhen,disables,oneOf,eitherOf,anyOf— all declarative. Predicates over field values and conditions, expressed without closures over component state. - The renderer stays generic. One
fields.map()loop readsenabled,required,fair, andreasonoff the availability map. No per-field branches. Noif (field === 'state'). @umpire/jsonround-trips. Rules, fields, conditions, and portable validators all serialize. What can’t serialize lands inexcludedinstead of disappearing silently.- 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.
Same config, with behavior
Section titled “Same config, with behavior”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.
Edit the config
Section titled “Edit the config”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.
schema.json
generic.tsx
- 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
billingEmailfield and anenabledWhenrule tying it toaccountType === 'business'. The renderer picks up the field without any component edits or new case statements. - Swap a validator — the portable
emailvalidator becomes amatchesregex. 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.
Three lines to hydrate
Section titled “Three lines to hydrate”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.
The renderer stays thin
Section titled “The renderer stays thin”{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.
What you still write in code
Section titled “What you still write in code”- 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 inexcludedon 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.
When this is overkill
Section titled “When this is overkill”- 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: truewould 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.
See also
Section titled “See also”- Quick Start — ten-minute walk through the rule primitives.
@umpire/json— the portable schema in full, includingexcludedandconditions.- @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.