Skip to content

Architecture

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)

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 ..


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 by order at every level
  • Extracts required from the validation array (a ‘required’ rule implies required: true)
  • Coerces numeric/date values to consistent types
  • Recursively normalises columnFields (row templates for composite fields)
  • Collects leaves only into fieldValues{} and fieldDetailMap{} (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 evaluates
  • safeEvaluate() — a restricted AST evaluator (no eval, 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, and undefined is encoded distinctly from null
  • Known limitations (% reserved, lossy string escapes) are documented in features.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 validateOn vs current mode (‘change’ | ‘blur’ | ‘submit’)
  • Sync validators resolve immediately — no state update overhead
  • Async validators: sets isValidating[field] = true, awaits Promise, 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 so scrollIntoView sees accurate layout
  • Marks async fields in-flight (isValidating), awaits Promise.all, then flushSync(setErrors(asyncErrors)) → scroll if no sync error
  • Returns { isError, errors } or boolean

Two invariants both functions maintain:

  • No-op publishes bail out — every setErrors/setIsValidating updater returns prev when nothing effectively changed, so a valid keystroke never triggers a second render pass
  • Scoped names resolve through valueResolversRef — a key like directors.r3.name isn’t a flat data key; its registered resolver closure reads the live row value. Flat names read straight from data. Emptiness everywhere is isEmptyValue: '' | null | undefined | [] ( 0 and false are 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 with setTree for multi-step navigation)
  • showField — per-field visibility
  • isValidating — async validation in-flight flags
  • allFieldsLoaded — true once all Suspense-wrapped fields have resolved

Key refs (read synchronously inside callbacks, avoid stale closures):

  • dataRefthe source of truth; updateData computes next synchronously, assigns the ref, then calls setData — so validate() right after updateData() sees fresh cross-field data
  • errorsRef, VALIDRef, stepAstateRef, fieldDetailMapRef, schemaRef
  • valueResolversRef — 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 validation
  • useFocusRef — DOM element registry + scroll-to-error; prunes disconnected nodes by liveness (ref callbacks can’t say which element unmounted), exposes registerElem for explicit-name registration (row scopes) and clearRefs for schema reloads
  • useFieldLoadingTracker — 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.

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:

  1. Skipped entirely if isHidden, or if their show expression is false — the whole subtree unmounts, which unregisters descendants’ validation rules automatically (hidden fields keep their values but are excluded from submit validation)
  2. Children render recursively, then the container resolves in priority order:
    • registered ContainerComponent (registry.registerContainer) — receives { field, children, index }, wrapped in FieldComponentSuspense so lazy container chunks block submit while loading
    • plain children — unregistered containers are invisible wrappers, useful for pure conditional groups

Leaf nodes:

  1. Wrapped in FieldComponentSuspense (Suspense boundary + loading tracker sentinels)
  2. Passed to memoized ConnectedField which:
    • Keeps dependencyData referentially stable until a dep value actually changes (a fresh slice every render would defeat consumer-side React.memo)
    • Creates onChange / onBlur callbacks whose identity depends only on this field’s own value — not the whole data object
    • Renders the registered FieldComponent with FieldComponentProps

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 loaded
  • useFieldLoadingTracker in useDynamicField tracks a Set<string> of pending names
  • handleContinue reads allFieldsLoadedRef (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 expression
  • computeIsRequired(field.required, dependencyData) — evaluates required expression
  • computeIsDisabled(field.disabled, dependencyData) — evaluates disabled expression
  • getCalculatedValue(field.calculateValue, dependencyData) — evaluates calc expression

Side effects (via useEffect):

  • registerBlockFieldVALID — updates VALIDRef when isRequired or validation rules change
  • registerBlockFieldShowState — updates showField state
  • registerBlockFieldStepState — tracks whether field is “satisfied” (for progress indicators)
  • Clears field data when the field hides itselfshouldClearDataOnHide counts 0 and false as values; getClearedValue resets arrays to [] and everything else to ''. Fields hidden by a container’s show expression unmount instead and keep their values
  • Syncs calculated value to data when it changes (compared via Object.is — a NaN result 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 scoped BlockFieldContextValue per 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 in valueResolversRef so 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 — composes useBlockField (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/moveRow enforce minRows/maxRows; removal sweeps the row’s errors by scoped prefix. Seeding precedence: value array > 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 through RenderDynamicFields inside 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 columnFields are 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.memo is @__PURE__-annotated, package.json sets sideEffects: false, and npm run check:treeshake bundles a flat-form fixture and fails if any repeater identifier leaks in.
  • Known v1 limits: isValidating isn’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.


What changesWho re-renders
User types in field AField A only (ConnectedField’s comparator; dependency slicing limits propagation)
Field A’s value is a dependency of field B’s show expressionField B’s ConnectedField (via depKeys slice in dependencyData)
Field A’s value flips a container’s show expressionThe container subtree mounts/unmounts
errors changesOnly the field(s) whose error changed (error passed as prop per-field)
isValidating changesOnly the validating field (passed as isFieldValidating prop)
User types in repeater row kThe repeater leaf (its array value changed) + row k’s field; all other rows bail via RepeaterRow’s memo
A row’s error changesThe repeater leaf (scoped-prefix compare) + that row
A form field referenced by row expressions changesThe repeater (hoisted dep) + rows whose merged data changed
Schema loads / tree changesFull 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).


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)