Skip to content

Features & expressions

The form UI is entirely defined by a JSON schema from your backend. No form layout code needs to change when the schema changes.

The schema is a recursive node tree: a node with fields is a container (groups and lays out children, holds no value), a node without is a leaf (a real input).

interface DrivenFormSchema {
id?: string | number
fields: FieldSchema[] // arbitrary container/leaf tree
}

Children are auto-sorted by order at every level. Nodes can carry any extra metadata — driven-form passes unknown keys through to your components untouched.


Containers nest arbitrarily — sections, rows, tabs, accordions are all just container nodes with different type strings. Form data stays flat regardless of nesting: every leaf name must be unique across the tree (the parser warns on duplicates), so expressions, validation, and errors never care about depth.

{
"fields": [
{ "name": "company", "type": "section", "label": "Company Details", "order": 1,
"fields": [
{ "name": "contactRow", "type": "row", "order": 1,
"fields": [
{ "name": "email", "type": "text", "order": 1 },
{ "name": "phone", "type": "text", "order": 2 }
]
}
]
}
]
}

Open in playground

Register layout components per container type — the engine renders the children recursively and hands them over:

function SectionCard({ field, children, index }: ContainerComponentProps) {
return (
<div className="card">
<h3>{index + 1}. {field.label}</h3>
{children}
</div>
)
}
registry.registerContainer('section', SectionCard)
registry.registerContainer('row', GridRow)

Container resolution order in the renderer:

  1. registered container component (registerContainer)
  2. renderSection render prop
  3. plain children — unregistered containers are invisible wrappers

A container with a show expression hides its whole subtree with ONE expression. Hidden descendants unmount, which unregisters their validation rules automatically — so fields inside can use plain required: true instead of duplicating the condition:

{ "name": "contactGroup", "type": "group", "show": "%sameAsOwner% == 'no'",
"fields": [
{ "name": "contactName", "type": "text", "required": true, "order": 1 }
]
}

Open in playground

group needs no registration — unregistered containers render children directly.

Note: fields hidden by a container’s show keep their values (restored when shown again); a leaf’s own show clears its value on hide.


driven-form ships no UI. You register your own React components for each node type — leaves and containers have separate namespaces:

const registry = createFieldRegistry()
// leaves = inputs
registry.register('text', MyTextInput)
registry.register('select', MyDropdown)
registry.register('date', MyDatePicker)
// containers = layout wrappers
registry.registerContainer('section', SectionCard)
registry.registerContainer('row', GridRow)

Leaf components receive FieldComponentProps and call useBlockField() internally to get isShow, isRequired, isDisabled, and the current error. Container components receive ContainerComponentProps (field, children, index). You own all markup and styling.


Fields can be shown or hidden based on the values of other fields. When a leaf field hides itself its value is automatically cleared so hidden data never reaches onSave.

{ "name": "gstNumber", "show": "%companyType% == 'pvtLtd' || %companyType% == 'publicLtd'" }

Containers support show too — one expression hides the whole subtree (see “Conditional groups” above).

Expressions use %fieldName% syntax. Supported operators: ==, !=, >, <, >=, <=, &&, ||, !, ternary ? :, arithmetic + - * /, Number(), String(), Array.includes().


Required state can be a static boolean or a live expression evaluated against current form data.

{ "name": "gstNumber", "required": "%companyType% == 'pvtLtd'" }

When the expression evaluates to false, the field is no longer required and its validation rule is removed from VALIDRef until the condition is true again.


Same expression syntax as show and required.

{ "name": "estimatedPremium", "disabled": true }
{ "name": "region", "disabled": "%country% != 'IN'" }

Fields can derive their value from an expression over other fields. Updated automatically whenever a dependency value changes — no user interaction needed.

{ "name": "totalPremium", "calculateValue": "Number(%sumInsured%) * 0.005", "disabled": true }

NameParameterDescription
requiredField must not be empty
minLengthvalue: numberString length ≥ n
maxLengthvalue: numberString length ≤ n
minvalue: numberNumeric value ≥ n
maxvalue: numberNumeric value ≤ n
emailValid email address
urlValid URL
patternvalue: stringRegex match

Register any function (sync or async) by name. Available across all forms that use the same registry instance.

validators.register('pancard', (value) =>
/[A-Z]{5}[0-9]{4}[A-Z]/.test(String(value)) ? null : 'Invalid PAN'
)

Cross-field validation: the second argument to every validator is the full formData.

validators.register('confirmPassword', (value, formData) =>
value === formData?.password ? null : 'Passwords do not match'
)

Return a Promise — the engine detects it automatically. No special flag needed.

validators.register('uniqueEmail', async (value) => {
const taken = await checkEmail(String(value))
return taken ? 'Email already registered' : null
})

While the promise is pending:

  • isValidating[fieldName] is true — use to show a spinner in your field component
  • isFieldValidating prop is true on the field component
  • handleContinue is not blocked by change/blur-triggered async checks — only submit blocks

A generation counter per field discards stale results: if the user types again before the previous async check resolves, the old result is silently dropped.

{ "name": "uniqueEmail", "validateOn": "blur" }
ValueWhen it runs
'change' (default)Every value change
'blur'When the field loses focus (and on submit)
'submit'Only when the form is submitted

On submit, all validators run regardless of their validateOn setting.


When handleContinue is called:

  1. Sync validators run and errors are applied immediately via flushSync → page scrolls to first error without waiting for async checks
  2. Async validators run concurrently (Promise.all), results applied when settled
  3. If no sync error existed, page scrolls to first async error after they resolve

Users see required / format errors instantly; server-side checks don’t block them.


On submit, the engine scrolls to and focuses the first invalid field automatically. Uses requestAnimationFrame to defer scrollIntoView by one frame so all pending React state is flushed before the animation starts — avoids mid-animation layout reflows that cause jumps in Chrome and Firefox.

Wire refFunc to your input’s DOM element (via the refFunc prop on FieldComponentProps):

<input ref={refFunc as React.Ref<HTMLInputElement>} ... />

Field components can be registered as React.lazy() imports. The engine wraps every field in a Suspense boundary automatically via FieldComponentSuspense.

registry.register('text', React.lazy(() => import('./TextInput')))
registry.register('select', React.lazy(() => import('./SelectInput')))

Why this matters for correctness: field validation rules are registered inside useBlockField, which only mounts after the lazy component resolves. Without tracking, a required field that hasn’t loaded yet would be silently skipped on submit.

Registered container components can be lazy too — a container that hasn’t resolved means its children haven’t registered validation rules either, so it blocks submit the same way.

allFieldsLoadedfalse while any lazy component is still downloading. handleContinue is blocked until this is true. Wire it to your button’s disabled state.

fieldFallback — ReactNode shown in each field’s Suspense boundary while loading. Pass a skeleton placeholder.

<DrivenForm fieldFallback={<FieldSkeleton />}>
{({ handleContinue, allFieldsLoaded }) => (
<button disabled={!allFieldsLoaded}>
{allFieldsLoaded ? 'Submit' : 'Loading fields…'}
</button>
)}
</DrivenForm>

Prefer registered container components (see above) for reusable layouts. For a one-off — a field placed inside a design-system grid, a custom wizard step, the same field surfaced in two places — render it standalone with <DrivenFormField> instead of building a container type just for that spot:

import { DrivenFormField } from 'driven-form/react'
<DrivenForm schema={schema} onSave={save} fieldRegistry={registry}>
{({ data, errors, fieldRegistry }) => (
<div className="my-custom-grid">
<DrivenFormField name="companyName" data={data} errors={errors} fieldRegistry={fieldRegistry} />
</div>
)}
</DrivenForm>

It renders through the exact same memoized machinery RenderDynamicFields uses for every leaf — full lifecycle wiring (show/required/disabled, calculated values, validation registration), same render-isolation guarantee. data/errors are required props (not read from context — see Headless / bring-your-own-UI for why data never lives in context); fieldRegistry is optional and inherited from context when <DrivenFormField> is nested inside a RenderDynamicFields subtree (e.g. inside a custom container component) — pass it explicitly (as above, from the bag) when used as a sibling of RenderDynamicFields, since that’s outside its registry-context boundary.

onUnregistered works the same as RenderDynamicFields ('warn' default, 'throw', 'silent') for both “no field named X in the schema” and “no component registered for its type”.


<RenderDynamicFields
onUnregistered="warn" // default: console.warn + skip
onUnregistered="throw" // throw an error (good for dev)
onUnregistered="silent" // skip silently
onUnregistered="custom" // render unregisteredFallback
unregisteredFallback={({ field }) => <div>Unknown: {field.type}</div>}
/>

<DrivenForm
schema={schema}
onSave={({ data }) => save(data)}
fieldRegistry={registry}
>
{({ handleContinue, errors, isValidating, allFieldsLoaded }) => (
<button onClick={handleContinue}>Submit</button>
)}
</DrivenForm>

DrivenForm manages schema loading, context wiring, and rendering automatically. Children is a render prop that receives DrivenFormBag.

Manual (useDynamicField) — advanced / multi-step

Section titled “Manual (useDynamicField) — advanced / multi-step”
const form = useDynamicField({ onSave, validatorRegistry })
useEffect(() => { form.loadFormData(schema) }, [schema])
return (
<BlockFieldProvider value={form.contextValue}>
<RenderDynamicFields
tree={form.tree}
data={form.data}
errors={form.errors}
fieldRegistry={registry}
isValidating={form.isValidating}
refFunc={form.refFunc}
/>
<button onClick={form.handleContinue}>Submit</button>
</BlockFieldProvider>
)

Use this when you need: multi-step form state management (tree/setTree), imperative setErrors for server validation responses, custom context composition, or access to onSubmitValidation independently from handleContinue.


Four stable, documented escape hatches for touching the form from outside a field component — a “use my location” button, a server-validation response, a custom wizard’s “next” button focusing the first field of the new step. All four are on useDynamicField’s return and on DrivenFormBag (so available whether you use <DrivenForm> or the manual mode).

Reads the current form data synchronously, without subscribing to it — safe inside event handlers and effects that shouldn’t re-run on every keystroke (data itself is fine to use in render, where subscribing is exactly what you want).

const handleExport = () => {
const values = getValues() // always current, even from a stale closure
downloadJson(values)
}

The imperative equivalent of a field’s own onChange — updates the value and (by default) validates it, exactly like typing would. Pass { validate: false } to only update the value. Returns a Promise<void> that resolves once validation settles; safe to leave unawaited.

<button onClick={() => setValue('city', 'Mumbai')}>Use my location</button>
// Silent prefill — no validation run:
setValue('referralCode', urlParams.get('ref'), { validate: false })

Set or clear a single field’s error directly — typically from a server-side validation response after submit. Pass null to clear.

const result = await handleContinue()
if (result.status === 'saved') {
const serverError = await api.save(result)
if (serverError) setError('email', serverError.message)
}

Programmatically scroll to (and focus) a field by name — the same mechanism onSubmitValidation uses to jump to the first error. Useful for a custom wizard focusing the first field of a new step, or jumping to a field from a summary view.

focusElem('companyName') // scroll + focus
focusElem('companyName', { focus: false, block: 'start' }) // scroll only

const validators = createValidatorRegistry()
// Simple
validators.register('noSpaces', (value) =>
String(value).includes(' ') ? 'No spaces allowed' : null
)
// Parameterised (factory pattern)
validators.register('divisibleBy', (n) => (value) =>
Number(value) % Number(n) === 0 ? null : `Must be divisible by ${n}`
)
// In schema: { "name": "divisibleBy", "value": 5 }
useDynamicField({ onSave, validatorRegistry: validators })
// or
<DrivenForm validatorRegistry={validators} ... />

Fields with isHidden: true carry a value in data and participate in expressions but are never rendered. Useful for computed or pre-populated backend values that other fields depend on.

{ "name": "policyId", "isHidden": true, "value": "POL-2024-001" }

stepAstateRef tracks whether each visible field is “satisfied” (has a value, or is not required). Accessible via blockFieldFns.stepAstateRef. Can be used to drive a multi-step progress indicator without subscribing to full form state.


Expressions are evaluated by a custom safe AST evaluator — no eval, no Function().

Supported expression features:

  • Comparison: ==, !=, >, <, >=, <=
  • Boolean: &&, ||, !
  • Arithmetic: +, -, *, /
  • Ternary: condition ? a : b
  • Member access: obj.property
  • Function calls: Number(), String(), Boolean(), Array.includes(), Math.*
  • String / number literals, array literals

Attempting to reference an unbound identifier logs a debug warning (in debug mode) suggesting the author may have meant to quote it as a string literal.

  • % is reserved as the field-reference delimiter. Before evaluation, every % is stripped from the expression, so the modulo operator is not available and a literal % inside a string (e.g. "%discount% == '10%'") breaks variable extraction. If you need modulo, compute the value in a custom validator or a registered field component instead. An escape syntax is planned.
  • String escape sequences are lossy. Inside expression string literals, \n parses as the literal character n (a backslash simply passes the next character through). Quotes can be escaped (\', \"), but control-character escapes are not supported. This is harmless for the equality comparisons expressions are designed for — avoid embedding multi-line strings in expressions.