Features & expressions
Schema-driven rendering
Section titled “Schema-driven rendering”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.
Nested containers (fields in fields)
Section titled “Nested containers (fields in fields)”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 } ] } ] } ]}Container components
Section titled “Container components”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:
- registered container component (
registerContainer) renderSectionrender prop- plain children — unregistered containers are invisible wrappers
Conditional groups
Section titled “Conditional groups”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 } ]}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.
Headless / bring-your-own-UI
Section titled “Headless / bring-your-own-UI”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 = inputsregistry.register('text', MyTextInput)registry.register('select', MyDropdown)registry.register('date', MyDatePicker)// containers = layout wrappersregistry.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.
Conditional show / hide
Section titled “Conditional show / hide”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().
Conditional required
Section titled “Conditional required”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.
Conditional disabled
Section titled “Conditional disabled”Same expression syntax as show and required.
{ "name": "estimatedPremium", "disabled": true }{ "name": "region", "disabled": "%country% != 'IN'" }Calculated (auto-derived) values
Section titled “Calculated (auto-derived) values”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 }Validation
Section titled “Validation”Built-in validators
Section titled “Built-in validators”| Name | Parameter | Description |
|---|---|---|
required | — | Field must not be empty |
minLength | value: number | String length ≥ n |
maxLength | value: number | String length ≤ n |
min | value: number | Numeric value ≥ n |
max | value: number | Numeric value ≤ n |
email | — | Valid email address |
url | — | Valid URL |
pattern | value: string | Regex match |
Custom validators
Section titled “Custom validators”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')Async validators
Section titled “Async validators”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]istrue— use to show a spinner in your field componentisFieldValidatingprop istrueon the field componenthandleContinueis 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.
Per-rule trigger control (validateOn)
Section titled “Per-rule trigger control (validateOn)”{ "name": "uniqueEmail", "validateOn": "blur" }| Value | When 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.
Sync / async submit split
Section titled “Sync / async submit split”When handleContinue is called:
- Sync validators run and errors are applied immediately via
flushSync→ page scrolls to first error without waiting for async checks - Async validators run concurrently (
Promise.all), results applied when settled - 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.
Focus-on-error with smooth scroll
Section titled “Focus-on-error with smooth scroll”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>} ... />Lazy field components
Section titled “Lazy field components”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.
allFieldsLoaded — false 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>Custom layout via <DrivenFormField>
Section titled “Custom layout via <DrivenFormField>”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”.
Unregistered field type handling
Section titled “Unregistered field type handling”<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>}/>Two usage modes
Section titled “Two usage modes”Composed (<DrivenForm>) — 80% case
Section titled “Composed (<DrivenForm>) — 80% case”<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.
Imperative API
Section titled “Imperative API”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).
getValues()
Section titled “getValues()”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)}setValue(name, value, options?)
Section titled “setValue(name, value, options?)”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 })setError(name, message)
Section titled “setError(name, message)”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)}focusElem(name, options?)
Section titled “focusElem(name, options?)”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 + focusfocusElem('companyName', { focus: false, block: 'start' }) // scroll onlyCustom validator registry
Section titled “Custom validator registry”const validators = createValidatorRegistry()
// Simplevalidators.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} ... />Hidden fields
Section titled “Hidden fields”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" }Step / progress tracking
Section titled “Step / progress tracking”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.
Expression safety
Section titled “Expression safety”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.
Known limitations
Section titled “Known limitations”%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,
\nparses as the literal charactern(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.