Architecture
Bird’s-eye view
Section titled “Bird’s-eye view”Backend API │ │ JSON schema — { fields: [...] } ▼parseSchema() ← schemaParser.ts │ │ tree[] (recursive nodes), fieldValues{}, fieldDetailMap{} (flat, leaves only) ▼useDynamicField() ← main orchestration hook │ ├─ useValidation() ← validate(), onSubmitValidation(), isValidating ├─ useFocusRef() ← refFunc, focusElem, clearRefs, registerElem ├─ useFieldLoadingTracker() ← allFieldsLoaded, markFieldPending/Loaded │ │ contextValue{} ▼BlockFieldProvider ← React context (stable callbacks, refs — never data/errors) │ ▼RenderDynamicFields ← recursive renderNode() over the tree │ (provides the registry via FieldRegistryContext) │ ├─ CONTAINER node (has `fields`) │ │ engine evaluates node.show — hidden containers skip the subtree │ ▼ │ registered ContainerComponent → plain children (invisible wrapper) │ │ (registered containers are Suspense-wrapped) │ └─ children render recursively ──┐ │ │ └─ LEAF node (no `fields`) ◄──────────┘ │ ▼ FieldComponentSuspense ← Suspense boundary + loading tracker sentinels │ ▼ ConnectedField (memoized) ← custom comparator = the performance contract │ │ FieldComponentProps (field, fieldValue, fieldError, onChange, onBlur, ...) ▼ <YourFieldComponent> ← registered in FieldRegistry │ ├─ useBlockField() ← field lifecycle (isShow, isRequired, isDisabled, calcValue) │ ├─ registerBlockFieldVALID() → VALIDRef │ ├─ registerBlockFieldStepState() → stepAstateRef │ └─ registerBlockFieldShowState() → showField state │ └─ useRepeaterField() ← composite fields only (see "Repeater layer") └─ RepeaterRow → BlockFieldProvider(row scope) → RenderDynamicFields (recursion — same engine, scoped context)The node tree model
Section titled “The node tree model”Everything in the schema is a node. A node with a fields array is a container
(it groups and lays out children, holds no value); a node without fields is a leaf
(a real input). Containers nest arbitrarily — sections, rows, tabs, accordions are all
just container nodes with different type strings.
Form data stays flat regardless of tree nesting. Every leaf name must be unique
across the whole tree (the parser warns on duplicates). Expressions (%fieldName%),
validation keys, errors, and isValidating never care about tree depth — containers
are purely presentational + conditional.
The one deliberate exception is repeaters: a composite leaf’s value is an array of
row objects (data.directors = [{ name, share }, ...]), and its rows’ registration
state lives under scoped keys (directors.r3.name) in the same flat maps. The
engine itself stays flat — scoping is a translation layer (see “Repeater layer”).
Because . is the scope separator, field and repeater names must not contain ..
Layer descriptions
Section titled “Layer descriptions”1. Schema layer (src/core/schemaParser.ts)
Section titled “1. Schema layer (src/core/schemaParser.ts)”Converts backend JSON into driven-form’s internal ParsedSchema.
- Recursive walk over
fields: children sorted byorderat every level - Extracts
requiredfrom the validation array (a ‘required’ rule impliesrequired: true) - Coerces numeric/date values to consistent types
- Recursively normalises
columnFields(row templates for composite fields) - Collects leaves only into
fieldValues{}andfieldDetailMap{}(flat) - Warns (always, not debug-gated) on duplicate node names
2. Expression layer (src/core/expressionParser.ts, safeEvaluator.ts, lruCache.ts)
Section titled “2. Expression layer (src/core/expressionParser.ts, safeEvaluator.ts, lruCache.ts)”Evaluates %fieldName%-style expressions at runtime.
parseVariables(expr)— extracts field name tokens (%city% > 0→["city"])parseExpression(expr, data, fieldDetailMap)— replaces tokens with live values then evaluatessafeEvaluate()— a restricted AST evaluator (noeval, no arbitrary code); supports comparisons, boolean logic, arithmetic, ternary, function calls (Number,String,Array.includes…)- All three caches (parsed variables, expression results, ASTs) are bounded LRUs
(
lruCache.ts; 500/1000/500 entries) so long-lived SPAs don’t leak - Expression-result cache keys are
expression + JSON.stringify(params)— value boundaries can’t collide, andundefinedis encoded distinctly fromnull - Known limitations (
%reserved, lossy string escapes) are documented infeatures.md→ Expression safety
3. Validation layer (src/react/hooks/useValidation.ts)
Section titled “3. Validation layer (src/react/hooks/useValidation.ts)”Two public functions:
validate(fieldName, value, { mode })
- Filters the field’s validators by
validateOnvs currentmode(‘change’ | ‘blur’ | ‘submit’) - Sync validators resolve immediately — no state update overhead
- Async validators: sets
isValidating[field] = true, awaitsPromise, applies result, clears flag - Generation counter per field discards stale async results when the user types again before the previous check resolves
onSubmitValidation()
- Single pass splits sync vs async validators
flushSync(setErrors(syncErrors))→focusElem(firstError)— DOM is committed before scroll starts soscrollIntoViewsees accurate layout- Marks async fields in-flight (
isValidating), awaitsPromise.all, thenflushSync(setErrors(asyncErrors))→ scroll if no sync error - Returns
{ isError, errors }orboolean
Two invariants both functions maintain:
- No-op publishes bail out — every
setErrors/setIsValidatingupdater returnsprevwhen nothing effectively changed, so a valid keystroke never triggers a second render pass - Scoped names resolve through
valueResolversRef— a key likedirectors.r3.nameisn’t a flat data key; its registered resolver closure reads the live row value. Flat names read straight fromdata. Emptiness everywhere isisEmptyValue:'' | null | undefined | [](0andfalseare values).
4. Orchestration layer (src/react/hooks/useDynamicField.ts)
Section titled “4. Orchestration layer (src/react/hooks/useDynamicField.ts)”The main hook. Owns all form state and exposes stable callbacks.
State owned:
data— all current field values (flat; repeater leaves hold row arrays)errors— current validation errors (flat; row errors under scoped keys)tree— parsed, ordered node tree (exposed withsetTreefor multi-step navigation)showField— per-field visibilityisValidating— async validation in-flight flagsallFieldsLoaded— true once all Suspense-wrapped fields have resolved
Key refs (read synchronously inside callbacks, avoid stale closures):
dataRef— the source of truth;updateDatacomputesnextsynchronously, assigns the ref, then callssetData— sovalidate()right afterupdateData()sees fresh cross-field dataerrorsRef,VALIDRef,stepAstateRef,fieldDetailMapRef,schemaRefvalueResolversRef— resolver closures for scoped (repeater-row) names- Registration refs (
VALIDRef,stepAstateRef) are mutated in place — nothing subscribes to their identity, and per-call spreading would be O(n²) across a mount
Sub-hooks composed:
useValidation— validate + submit validationuseFocusRef— DOM element registry + scroll-to-error; prunes disconnected nodes by liveness (ref callbacks can’t say which element unmounted), exposesregisterElemfor explicit-name registration (row scopes) andclearRefsfor schema reloadsuseFieldLoadingTracker— lazy field loading state
loadFormData(schema) — resets all state (including resolvers and focus refs) and parses the new schema.
handleContinue() — guards on allFieldsLoadedRef, runs onSubmitValidation, calls onSave.
Resolves with a discriminated result: { status: 'saved' },
{ status: 'blocked', reason: 'fields-loading' }, or
{ status: 'validation-failed', errors } — safe to ignore when wired straight to a button.
5. Context layer (src/react/context/)
Section titled “5. Context layer (src/react/context/)”BlockFieldContext carries stable callbacks (not data) so field components can
register themselves without subscribing to the full form state. Re-renders only when
fieldDetailMap or a callback reference changes.
Contents: updateData, validate, setErrors, focusElem, all register*/unregister*
callbacks, errorsRef, stepAstateRef, VALIDRef, validatorRegistry,
markFieldPending, markFieldLoaded — plus the optional scoped-field members:
valueResolversRef, registerFocusTarget, dataRef (read-only live view), scopePath.
Not in context: data, errors, showField — these are passed as props per-field
to prevent every field re-rendering on every keystroke. (dataRef/errorsRef are refs:
reading them never subscribes.)
FieldRegistryContext makes the registry reachable below a RenderDynamicFields
without re-threading it — this is how a repeater row renders the template with the same
registry the outer renderer was given. The fieldRegistry prop wins; context is the
fallback (so the prop is optional when nested).
6. Render layer (src/react/components/RenderDynamicFields.tsx)
Section titled “6. Render layer (src/react/components/RenderDynamicFields.tsx)”Recursive renderNode() over the tree prop.
Container nodes:
- Skipped entirely if
isHidden, or if theirshowexpression is false — the whole subtree unmounts, which unregisters descendants’ validation rules automatically (hidden fields keep their values but are excluded from submit validation) - Children render recursively, then the container resolves in priority order:
- registered ContainerComponent (
registry.registerContainer) — receives{ field, children, index }, wrapped inFieldComponentSuspenseso lazy container chunks block submit while loading - plain children — unregistered containers are invisible wrappers, useful for pure conditional groups
- registered ContainerComponent (
Leaf nodes:
- Wrapped in
FieldComponentSuspense(Suspense boundary + loading tracker sentinels) - Passed to memoized
ConnectedFieldwhich:- Keeps
dependencyDatareferentially stable until a dep value actually changes (a fresh slice every render would defeat consumer-sideReact.memo) - Creates
onChange/onBlurcallbacks whose identity depends only on this field’s own value — not the wholedataobject - Renders the registered
FieldComponentwithFieldComponentProps
- Keeps
The performance contract lives in connectedFieldPropsAreEqual: a field re-renders
only when its own value, its own error, its validating flag, or a declared dependency
value changes. All value comparisons use Object.is (a NaN calculated value must not
defeat memoization). Composite leaves additionally compare the error entries under
their scoped prefix (directors.), since their rows’ errors live in the flat map.
Enforced by RenderDynamicFields.renderCount.test.tsx and demonstrated live on the
/performance example page.
7. Lazy loading (src/react/components/FieldComponentSuspense.tsx)
Section titled “7. Lazy loading (src/react/components/FieldComponentSuspense.tsx)”Enables React.lazy() in the field registry without losing required-field validation safety.
FieldPendingSentinel(outside Suspense) — mounts immediately, marks field pending; cleanup fires on full unmount, marks field loaded (no longer blocking)FieldLoadedSentinel(inside Suspense) — only mounts after lazy resolve; marks field loadeduseFieldLoadingTrackerinuseDynamicFieldtracks aSet<string>of pending nameshandleContinuereadsallFieldsLoadedRef(not state) to block submit synchronously while any field component is still downloading- Applies to registered containers too: a lazy container that hasn’t resolved means its children haven’t registered validation rules — so it blocks submit the same way
- Inside a repeater row, sentinels register under the row’s scoped name
8. Field lifecycle (src/react/hooks/useBlockField.ts)
Section titled “8. Field lifecycle (src/react/hooks/useBlockField.ts)”Called inside every leaf <YourFieldComponent>. Computes derived state and keeps the engine in sync.
Per render:
computeIsShow(field.show, dependencyData)— evaluates show expressioncomputeIsRequired(field.required, dependencyData)— evaluates required expressioncomputeIsDisabled(field.disabled, dependencyData)— evaluates disabled expressiongetCalculatedValue(field.calculateValue, dependencyData)— evaluates calc expression
Side effects (via useEffect):
registerBlockFieldVALID— updatesVALIDRefwhen isRequired or validation rules changeregisterBlockFieldShowState— updatesshowFieldstateregisterBlockFieldStepState— tracks whether field is “satisfied” (for progress indicators)- Clears field data when the field hides itself —
shouldClearDataOnHidecounts0andfalseas values;getClearedValueresets arrays to[]and everything else to''. Fields hidden by a container’s show expression unmount instead and keep their values - Syncs calculated value to
datawhen it changes (compared viaObject.is— aNaNresult must not re-publish forever)
9. Repeater layer (src/react/repeater/ — tree-shakable)
Section titled “9. Repeater layer (src/react/repeater/ — tree-shakable)”First-class array fields via recursive composition, not a special engine mode (see
ComposabilityPlan.md for the full design, composite-fields.md for the consumer guide).
rowScope.ts— builds a scopedBlockFieldContextValueper row. Every function translates between row-local names and the parent’s world:updateData('share', v)becomes an immutable splice of the rows array published upward; registrations land under scoped keys (directors.r3.share) and install a value resolver invalueResolversRefso submit validation can read them. Scopes read the parent context through a ref, so they stay identity-stable across context rebuilds. Nesting chains naturally (directors.r1.addresses.r0.city).useRepeaterField.ts— composesuseBlockField(a repeater is still a field: show/required/disabled, repeater-level validators receive the whole rows array, hide-clears to[]). Owns the row ID table — stable generated IDs kept beside the data, never inside it, so the submit payload stays clean and remove/reorder never re-keys anything.addRow/removeRow/moveRowenforceminRows/maxRows; removal sweeps the row’s errors by scoped prefix. Seeding precedence:valuearray >initialRows>minRows> 0.RepeaterRow.tsx— memoized on the row’s value-object identity (sound under the immutable-splice discipline) + a content-compare of its error slice. Renders the template throughRenderDynamicFieldsinside the row scope, with the registry inherited from context. Per-row extras (remove buttons) must be siblings, not children — inline children would defeat the memo.- Row expressions resolve row-local names first, then fall back to form scope
(shadowing is row-wins). External refs used inside
columnFieldsare hoisted into the repeater’s own dependency list (fieldDependencies.ts), so the repeater re-renders when a referenced form field changes and passes the value into rows. - Tree-shaking is guaranteed: the engine never imports this directory, module-level
React.memois@__PURE__-annotated,package.jsonsetssideEffects: false, andnpm run check:treeshakebundles a flat-form fixture and fails if any repeater identifier leaks in. - Known v1 limits:
isValidatingisn’t sliced per-row; row-field custom validators receive root form data (use row expressions for row-local conditions).
10. Composed component (src/react/components/DrivenForm.tsx)
Section titled “10. Composed component (src/react/components/DrivenForm.tsx)”Composes all layers for the 80% case. Accepts a children render prop that receives DrivenFormBag.
<DrivenForm schema={schema} onSave={save} fieldRegistry={registry} fieldFallback={<Skeleton />}> {({ handleContinue, errors, isValidating, allFieldsLoaded }) => ( <button onClick={handleContinue} disabled={!allFieldsLoaded}>Submit</button> )}</DrivenForm>The load effect is reset-loop guarded: different schema id → reload; otherwise
content-compared, so a same-id schema update still reloads but a re-created inline
literal (identical content every render) does not — with a one-time console warning
suggesting the schema be memoized.
For advanced cases (multi-step, imperative access, custom data flow) use useDynamicField directly.
Re-render budget
Section titled “Re-render budget”| What changes | Who re-renders |
|---|---|
| User types in field A | Field A only (ConnectedField’s comparator; dependency slicing limits propagation) |
Field A’s value is a dependency of field B’s show expression | Field B’s ConnectedField (via depKeys slice in dependencyData) |
Field A’s value flips a container’s show expression | The container subtree mounts/unmounts |
errors changes | Only the field(s) whose error changed (error passed as prop per-field) |
isValidating changes | Only the validating field (passed as isFieldValidating prop) |
| User types in repeater row k | The repeater leaf (its array value changed) + row k’s field; all other rows bail via RepeaterRow’s memo |
| A row’s error changes | The repeater leaf (scoped-prefix compare) + that row |
| A form field referenced by row expressions changes | The repeater (hoisted dep) + rows whose merged data changed |
Schema loads / tree changes | Full re-render (expected — new form) |
Enforced by RenderDynamicFields.renderCount.test.tsx (flat) and
repeater.test.tsx (rows, incl. a container probe that catches a broken
RepeaterRow memo that field-level counters can’t see).
Dependency graph (module level)
Section titled “Dependency graph (module level)”DrivenForm └─ useDynamicField ├─ useValidation (scoped names via valueResolversRef) ├─ useFocusRef └─ useFieldLoadingTracker └─ BlockFieldProvider └─ RenderDynamicFields (recursive; provides FieldRegistryContext) ├─ <ContainerComponent> ← registry.registerContainer │ └─ (children render recursively) └─ FieldComponentSuspense └─ ConnectedField (memoized — the performance contract) └─ <YourFieldComponent> ← registry.register ├─ useBlockField │ └─ useBlockFieldContext └─ useRepeaterField ← composite fields only ├─ useBlockField ├─ rowScope (scoped BlockFieldContextValue) └─ RepeaterRow └─ BlockFieldProvider(scope) └─ RenderDynamicFields ← recursion
Tree-shake boundary: nothing left of the ← arrows imports src/react/repeater/.Guarded by scripts/check-treeshake.mjs (npm run check:treeshake).
Core (no React): schemaParser ─► expressionParser ─► safeEvaluator ─► lruCache blockFieldHelpers ─► expressionParser validatorRegistry fieldDependencies ─► expressionParser (hoists external columnFields refs)