rhf-dynamic-forms
v1.12.0
Published
Configuration-driven form generation library for React
Maintainers
Readme
Dynamic Forms
Configuration-driven form engine for React with react-hook-form and Zod integration.
Table of Contents
- Overview
- Installation
- Quick Start
- Configuration Reference
- Usage Examples
- API Reference
- Creating Field Components
- Creating Container Components
- Development
- Tech Stack
- Contributing
- License
Overview
Dynamic Forms is a pure rendering engine — it traverses a JSON configuration tree and delegates all visual rendering to consumer-provided components. The engine handles validation, visibility, form state, and Zod schema generation. You control how everything looks.
Key Principles:
- Zero styles, zero defaults — the engine renders nothing visual on its own
- Two rendering paths — field →
components.fields[type], container →components.containers[variant] - Single entry point —
ComponentRegistryis the only way to provide visual implementations - Config drives everything — variant determines what container component renders
Key Features:
- Define forms as JSON configuration
- Flexible validation: external resolver, Zod schema, or config-driven
- Full react-hook-form integration
- Nested field paths with dot notation
- Conditional visibility and validation with JSON Logic
- Field dependencies with cascading resets
- Select fields with static/dynamic options
- Array fields for repeatable groups
- Variant-based container system (row, column, section, or any custom variant)
- Custom field components with type-safe props
- Meta pass-through for consumer-specific data
Installation
npm install rhf-dynamic-forms
# or
pnpm add rhf-dynamic-forms
# or
yarn add rhf-dynamic-formsPeer Dependencies:
npm install react@^19 react-dom@^19Quick Start
import {
DynamicForm,
type FormConfiguration,
type ComponentRegistry,
} from 'rhf-dynamic-forms';
// 1. Define your form configuration
const config: FormConfiguration = {
name: "Contact Form",
elements: [
{
type: "text",
name: "fullName",
label: "Full Name",
validation: { required: true, minLength: 2 },
},
{
type: "email",
name: "email",
label: "Email Address",
validation: { required: true },
},
],
};
// 2. Create a unified component registry
const components: ComponentRegistry = {
fields: {
text: ({ field, fieldState, config }) => (
<div>
<label>{config.label}</label>
<input {...field} placeholder={config.placeholder} />
{fieldState.error && <span>{fieldState.error.message}</span>}
</div>
),
email: ({ field, fieldState, config }) => (
<div>
<label>{config.label}</label>
<input {...field} type="email" placeholder={config.placeholder} />
{fieldState.error && <span>{fieldState.error.message}</span>}
</div>
),
boolean: ({ field, config }) => (
<label>
<input {...field} type="checkbox" checked={field.value} />
{config.label}
</label>
),
phone: ({ field, config }) => (
<div>
<label>{config.label}</label>
<input {...field} type="tel" />
</div>
),
date: ({ field, config }) => (
<div>
<label>{config.label}</label>
<input {...field} type="date" />
</div>
),
// `options` accepts a static list, a data-map descriptor, or a resolver —
// useSelectOptions resolves all three. See "Select Options" below.
select: ({ field, config }) => {
const { options } = useSelectOptions(config, field.name);
return (
<div>
<label>{config.label}</label>
<select {...field}>
{options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
);
},
array: ({ field, config }) => <div>{/* array implementation */}</div>,
},
};
// 3. Render the form
function App() {
return (
<DynamicForm
config={config}
components={components}
onSubmit={(data) => console.log('Submitted:', data)}
>
<button type="submit">Submit</button>
</DynamicForm>
);
}Configuration Reference
FormConfiguration
The root configuration object that defines your form structure.
interface FormConfiguration {
name?: string; // Optional form identifier
elements: FormElement[]; // Array of fields and containers
}Field Types
The engine is type-agnostic — any string is a valid field type. Consumers register components for each type they use via ComponentRegistry.fields. The table below lists common conventions:
| Type | Description | Default Schema |
|------|-------------|----------------|
| text | Single-line text input | z.string() |
| email | Email input with validation | z.string().email() |
| boolean | Checkbox or toggle | z.boolean() |
| phone | Telephone number input | z.string() |
| date | Date picker | z.string() |
| select | Dropdown/multi-select with options | Structural (auto-detected) |
| array | Repeatable field groups | Structural (auto-detected) |
| custom | User-defined component | z.unknown() |
| container | Layout container (variant-based) | N/A (layout element) |
| any string | Consumer-defined type | z.unknown() (configurable via setSchemaMap) |
Field Element Structure
The engine is type-agnostic — type is an open string, not a closed enum. Consumers can use any string (e.g. "textarea", "currency", "rich-text"). The engine only distinguishes "container" from everything else.
interface BaseFieldElement {
type: string; // Any string — consumer-defined field type
name: string; // Field path (supports dot notation)
label?: string; // Display label
placeholder?: string; // Placeholder text
defaultValue?: string | number | boolean | null;
validation?: ValidationConfig; // Validation rules
visible?: JsonLogicRule; // Conditional visibility
dependsOn?: string; // Field dependency for cascading
resetOnParentChange?: boolean; // Reset when parent changes
meta?: Record<string, unknown>; // Consumer-specific metadata (passed through)
}Select Options
A select field's options accepts three shapes (SelectOptionsConfig). They are discriminated in this exact order: Array.isArray(options) → static; options.type === 'resolver' → resolver; otherwise (object with sourceField) → data-map.
// 1) Static list
{ type: 'select', name: 'bank', options: [{ label: 'Chase', value: 'chase' }] }
// 2) Data-map — derive options from an array already in the form data
{
type: 'select',
name: 'bank',
options: {
sourceField: 'data.bankAccounts', // dot-path to the source ARRAY in form values
labelPath: 'bankName', // dot-path within each item → option label
valuePath: 'bankAccountNumber', // dot-path within each item → option value
},
}
// 2b) Data-map over a primitive array — omit labelPath/valuePath
// ["Chase", "Citibank"] → [{ label: 'Chase', value: 'Chase' }, ...]
{ type: 'select', name: 'bank', options: { sourceField: 'data.bankAccounts' } }
// 3) Resolver — call a named function registered on the form
{ type: 'select', name: 'bank', options: { type: 'resolver', name: 'bankAccountsResolver' } }Resolvers are registered on the component registry and may be sync or async:
const components: ComponentRegistry = {
fields: { select: MySelect /* ...others */ },
resolvers: {
bankAccountsResolver: async ({ formValues, fieldName }) =>
fetchBankAccounts(formValues.customerId), // → SelectOption[] | Promise<SelectOption[]>
},
};When a resolver reads other fields (e.g. formValues.customerId), declare that
dependency with dependsOn so the resolver re-runs when it changes — this reuses
the same field-dependency mechanism as cascading selects:
{ type: 'select', name: 'bank', dependsOn: 'customerId',
options: { type: 'resolver', name: 'bankAccountsResolver' } }Consuming options in a select component — the useSelectOptions hook resolves all three shapes, recomputes data-map options when the sourceField data changes (and resolver options when the dependsOn value changes), and exposes async loading state. The returned options array must not be mutated in place:
import {
type BaseFieldComponent,
type SelectFieldElement,
useSelectOptions,
} from 'rhf-dynamic-forms';
const MySelect: BaseFieldComponent = ({ field, config: baseConfig }) => {
const config = baseConfig as SelectFieldElement;
const { options, isLoading } = useSelectOptions(config, field.name);
if (isLoading) {
return <span>Loading…</span>;
}
return (
<select {...field}>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
);
};Need raw resolution outside React (e.g. tests)? Use the pure resolveSelectOptions(options, { formValues, fieldName, resolvers }).
Deprecation: the older
optionsSourcedescriptor (static | map | api | search | resolver) still works unchanged, but is deprecated in favor ofoptions. When both are present,optionswins. Note thatuseSelectOptionsonly resolves the newoptionsfield; the legacyoptionsSource(including itsresolvervariant) remains entirely consumer-resolved as before — thecomponents.resolversregistry applies only tooptions: { type: 'resolver' }. MigrateoptionsSourceusages tooptionsto opt into library resolution.
Validation Configuration
interface ValidationConfig {
required?: boolean; // Field must have a value
minLength?: number; // Minimum text length
maxLength?: number; // Maximum text length
pattern?: string; // Regex pattern
message?: string; // Custom error message
condition?: JsonLogicRule; // JSON Logic condition
}Validate on mount
By default the form follows React Hook Form semantics: errors do not appear
until the user interacts with the field (per the chosen mode). For review
workflows where initialData may already contain invalid values (e.g. an
extracted email of "N/A"), set validateOnMount so the form runs one
validation pass right after mount and surfaces those errors immediately.
<DynamicForm
config={config}
initialData={extractedData}
components={components}
onSubmit={handleSubmit}
validateOnMount
/>The trigger fires once per mount. Subsequent re-renders do not re-trigger;
remounting the component (e.g. by changing key) will trigger a fresh pass.
Container Layout
Containers are layout wrappers resolved by variant through the component registry. The engine only knows two things: field and container. What the container IS (row, column, section, card, grid) is decided by the consumer.
interface ContainerElement {
type: "container";
variant?: string; // Looked up in components.containers[variant]
children?: FormElement[]; // Child elements rendered inside
visible?: JsonLogicRule; // Conditional visibility
meta?: Record<string, unknown>; // Consumer-specific metadata (width, title, etc.)
}Variant resolution:
{ type: "container", variant: "section" }→components.containers["section"]{ type: "container", variant: "row" }→components.containers["row"]{ type: "container", variant: "column" }→components.containers["column"]{ type: "container" }→ resolves tocomponents.containers["default"]; if no"default"is registered, children render in a bare<Fragment>without any wrapper
Two-column row example:
{
type: "container",
variant: "row",
children: [
{
type: "container",
variant: "column",
meta: { width: "calc(50% - 0.5rem)" },
children: [
{ type: "text", name: "firstName", label: "First Name" },
],
},
{
type: "container",
variant: "column",
meta: { width: "calc(50% - 0.5rem)" },
children: [
{ type: "text", name: "lastName", label: "Last Name" },
],
},
],
}Section example:
{
type: "container",
variant: "section",
meta: {
title: "Personal Information",
description: "Enter your details below.",
},
children: [
{ type: "text", name: "firstName", label: "First Name" },
{ type: "email", name: "email", label: "Email" },
],
}Usage Examples
Nested Field Paths
Use dot notation to create nested data structures:
const config: FormConfiguration = {
elements: [
{ type: "text", name: "contact.firstName", label: "First Name" },
{ type: "text", name: "contact.lastName", label: "Last Name" },
{ type: "email", name: "contact.email", label: "Email" },
],
};
// Submitted: { contact: { firstName: "John", lastName: "Doe", email: "[email protected]" } }Numeric path segments are treated as array indexes, matching React Hook Form path syntax:
const config: FormConfiguration = {
elements: [
{
type: "text",
name: "extraction_result.policies.0.policy_number",
label: "Policy Number",
},
],
};
// Submitted: { extraction_result: { policies: [{ policy_number: "ABC-123" }] } }Two-Column Layout
{
type: "container",
variant: "row",
children: [
{
type: "container",
variant: "column",
meta: { width: "calc(50% - 0.5rem)" },
children: [
{ type: "email", name: "email", label: "Email", validation: { required: true } },
],
},
{
type: "container",
variant: "column",
meta: { width: "calc(50% - 0.5rem)" },
children: [
{ type: "phone", name: "phone", label: "Phone" },
],
},
],
}Section Layout
import type { ContainerComponent, CustomContainerRegistry } from 'rhf-dynamic-forms';
// Section — reads title/description from meta
const Section: ContainerComponent = ({ config, children }) => (
<fieldset>
{config.meta?.title && <legend>{config.meta.title as string}</legend>}
{config.meta?.description && <p>{config.meta.description as string}</p>}
<div>{children}</div>
</fieldset>
);
// Row — horizontal flex
const Row: ContainerComponent = ({ children }) => (
<div style={{ display: 'flex', gap: '1rem' }}>{children}</div>
);
// Column — reads width from meta
const Column: ContainerComponent = ({ config, children }) => (
<div style={{ width: (config.meta?.width as string) ?? 'auto' }}>{children}</div>
);
const containers: CustomContainerRegistry = {
section: Section,
row: Row,
column: Column,
};Three-Column Layout
{
type: "container",
variant: "row",
children: [
{
type: "container",
variant: "column",
meta: { width: "calc(33.333% - 0.667rem)" },
children: [{ type: "text", name: "company", label: "Company" }],
},
{
type: "container",
variant: "column",
meta: { width: "calc(33.333% - 0.667rem)" },
children: [{ type: "select", name: "dept", label: "Department", options: [] }],
},
{
type: "container",
variant: "column",
meta: { width: "calc(33.333% - 0.667rem)" },
children: [{ type: "text", name: "title", label: "Job Title" }],
},
],
}Custom Field Component
import { defineCustomComponent, type ComponentRegistry } from 'rhf-dynamic-forms';
import { z } from 'zod/v4';
const RatingField = defineCustomComponent({
component: ({ field, componentProps }) => (
<div className="rating">
{Array.from({ length: componentProps.maxStars }, (_, i) => (
<button key={i} type="button" onClick={() => field.onChange(i + 1)}>
{i < (field.value as number ?? 0) ? '\u2605' : '\u2606'}
</button>
))}
</div>
),
propsSchema: z.object({ maxStars: z.number().int().min(1).max(10).default(5) }),
defaultProps: { maxStars: 5 },
displayName: 'RatingField',
});
const components: ComponentRegistry = {
fields: { /* ... */ },
custom: { RatingField },
};
// In config:
{ type: "custom", name: "rating", label: "Rate us", component: "RatingField", componentProps: { maxStars: 10 } }JSON Logic Conditional Validation
{
type: "phone",
name: "phone",
label: "Phone Number",
validation: {
condition: {
or: [
{ "!": { var: "hasPhone" } },
{ and: [{ var: "hasPhone" }, { regex_match: ["^[0-9]{10}$", { var: "phone" }] }] },
],
},
message: "Please enter a valid 10-digit phone number",
},
}Automatic cross-field revalidation — When validation.condition references other fields via { var: "otherField" }, the engine collects those references as peers and re-runs this field's validation whenever any peer value changes. Wired internally through React Hook Form's useController({ rules: { deps } }), so no manual form.trigger() calls or watch subscriptions are needed.
// Changing `hasPhone` automatically revalidates `phone` —
// `phone` derived `hasPhone` as a peer from its own validation.condition.
{ type: "boolean", name: "hasPhone", label: "I have a phone" },
{ type: "phone", name: "phone", label: "Phone Number",
validation: {
condition: { or: [{ "!": { var: "hasPhone" } }, { var: "phone" }] },
message: "Phone is required when 'I have a phone' is checked",
},
}Per-row conditions on array itemFields — validation.condition also works on fields inside an array's itemFields. The condition is evaluated once per row; { var: "$item.<path>" } resolves against the current row, while plain { var: "<path>" } still resolves against the whole form — so a top-level field can gate a per-row rule. Failures are reported at the row-scoped path (e.g. insurers.0.claimNumber.value), so the error lands on the right input of the right row.
{ type: "boolean", name: "isMedical", label: "Is Medical" },
{
type: "array",
name: "insurers",
label: "Insurers",
itemFields: [
{
type: "text",
name: "claimNumber.value",
label: "Claim Number",
validation: {
// Per row: at least one of Claim Number / Policy Number is required
condition: {
or: [
{ var: "$item.claimNumber.value" },
{ var: "$item.policyNumber.value" },
],
},
message: "Provide a Claim Number or a Policy Number.",
},
},
{ type: "text", name: "policyNumber.value", label: "Policy Number" },
{
type: "text",
name: "name.value",
label: "Name",
validation: {
// Top-level gate + row-relative check in one rule
condition: {
if: [{ var: "isMedical" }, { "!!": { var: "$item.name.value" } }, true],
},
message: "Name required when Medical.",
},
},
],
}Available JSON Logic Operations:
- Standard:
var,and,or,!,==,!=,>,<,>=,<=,if - Custom:
regex_match-["pattern", { var: "fieldName" }]
Consumer-Registered Operations
The library stays agnostic about domain semantics — register your own operations and reference them by name in config conditions:
import { addJsonLogicOperation } from "rhf-dynamic-forms";
addJsonLogicOperation("dateValid", (value) => isValidDate(value));
// config: { "condition": { "dateValid": [{ "var": "dob" }] } }Always register through addJsonLogicOperation, never via a direct
json-logic-js import: your bundler may resolve a different copy of
json-logic-js than the one the library evaluates with (typical with linked
packages), and operations registered there are invisible to the validator.
Re-registering a name overwrites the previous implementation.
Error Behavior (fail closed)
A validation condition that throws during evaluation — most commonly a rule referencing an unregistered operation — is treated as failed: the field shows its validation message and submission stays blocked. One broken rule never disables the rest of the form's validation.
Visibility Control
{ type: "boolean", name: "showNickname", label: "Show nickname", defaultValue: false },
{ type: "text", name: "nickname", label: "Nickname", visible: { "==": [{ var: "showNickname" }, true] } }API Reference
DynamicForm Props
interface DynamicFormProps {
config: FormConfiguration; // Form configuration
components: ComponentRegistry; // Component implementations (required)
onSubmit: (data: FormData) => void; // Submit handler
initialData?: FormData;
onChange?: (data: FormData, field: string) => void;
onError?: (errors: unknown) => void;
onReset?: () => void;
onValidationChange?: (errors: unknown, isValid: boolean) => void;
mode?: "onChange" | "onBlur" | "onSubmit" | "onTouched" | "all";
validateOnMount?: boolean;
invisibleFieldValidation?: "skip" | "validate" | "warn";
fieldWrapper?: FieldWrapperFunction;
className?: string;
style?: CSSProperties;
id?: string;
children?: React.ReactNode;
ref?: React.Ref<DynamicFormRef>;
}invisibleFieldValidation controls what happens to validation errors on fields hidden by a visible rule: "skip" (default) discards them, "warn" keeps them with type: "warning", "validate" keeps them as regular blocking errors. Hidden state cascades to nested errors — when an array field is hidden, its per-row errors (e.g. insurers.0.claimNumber.value) are skipped or downgraded along with it, so a hidden array never blocks submission.
DynamicFormRef
Imperative handle attached to <DynamicForm ref={formRef} />. Lets the parent
read form state and drive the form without subscribing through context.
interface DynamicFormRef {
/** Snapshot of all form values at the time of the call (non-reactive). */
getValues: () => FormData;
/** Set a single field value. Accepts nested paths ("source.country"). */
setValue: (name: string, value: unknown) => void;
/** Subscribe-style read of all values (reactive when called inside render). */
watchAll: () => FormData;
/** Subscribe-style read of a single field. */
watchField: (name: string) => unknown;
/** Reset the form. When called without arguments, restores `initialData`. */
reset: (values?: FormData) => void;
/** Run validation for one field or for the whole form. */
trigger: (name?: string) => Promise<boolean>;
/** Current overall validity from `formState.isValid`. */
getIsValid: () => boolean;
/** Current validation errors tree from `formState.errors`. */
getErrors: () => Record<string, unknown>;
/**
* Whether any field diverges from its `defaultValues` baseline.
* Tracked natively by react-hook-form, so reverting an edited field back
* to its initial value flips this to `false` again — useful for "discard
* changes?" prompts without hand-rolled deep-equality on form snapshots.
*/
getIsDirty: () => boolean;
}Example — unsaved-changes guard driven by getIsDirty:
const formRef = useRef<DynamicFormRef>(null);
const handleCancel = () => {
if (formRef.current?.getIsDirty()) {
setShowUnsavedDialog(true);
return;
}
navigateAway();
};
return (
<DynamicForm
ref={formRef}
config={config}
initialData={initialData}
components={components}
onSubmit={onSubmit}
/>
);ComponentRegistry
Single entry point for all visual implementations:
interface ComponentRegistry {
fields: FieldComponentRegistry; // Required: standard field components
custom?: CustomComponentRegistry; // Optional: custom field components
containers?: CustomContainerRegistry; // Optional: container components by variant
fallback?: {
all?: FallbackComponent; // Optional: fallback for any missing field/custom component
field?: FallbackComponent; // Optional: fallback for missing field types
custom?: FallbackComponent; // Optional: fallback for missing custom components
};
}The engine has no built-in visual fallback. If a field type is not registered
and no fallback is configured, no visual field is rendered after a console
warning; the field may still participate in form state and validation. Missing
custom components remain configuration errors unless fallback.custom or
fallback.all is configured.
Fallback resolution is specific-first:
- Missing field type:
fallback.field ?? fallback.all - Missing custom component:
fallback.custom ?? fallback.all
Fallback components receive normal field props, so fallbacks for validated
fields should either wire field to an input or render fieldState.error.
Container variants are separate: missing containers keep the existing behavior
of rendering their children unwrapped via
components.containers[variant] ?? components.containers.default.
const UnsupportedFieldFallback: FallbackComponent = ({
config,
field,
fieldState,
missingComponent,
}) => (
<div>
<label htmlFor={field.name}>{config.label ?? field.name}</label>
<input id={field.name} {...field} />
{fieldState.error && <span>{fieldState.error.message}</span>}
<small>
Unsupported {missingComponent.kind}: {missingComponent.requested}
</small>
</div>
);
const components: ComponentRegistry = {
fields: {
text: TextField,
email: EmailField,
},
fallback: {
all: UnsupportedFieldFallback,
},
};Hooks
const { config, form } = useDynamicFormContext();
const context = useDynamicFormContextSafe(); // returns null outside formExports
// Components
export { DynamicForm, DynamicFormContext };
export { useDynamicFormContext, useDynamicFormContextSafe };
export { defineCustomComponent };
export { parseConfiguration, safeParseConfiguration, ConfigurationError };
// Types
export type {
FormConfiguration, FormElement, FieldElement, ContainerElement, LayoutElement,
BaseFieldElement, BaseFieldProps, BaseFieldComponent,
ValidationConfig, FormData, DynamicFormProps, DynamicFormRef,
ComponentRegistry, FieldComponentRegistry, CustomComponentRegistry, CustomContainerRegistry,
ContainerComponent, ContainerProps, FallbackComponent, FallbackComponentProps,
FallbackComponentRegistry, MissingComponentInfo, MissingComponentKind,
SelectFieldComponent, ArrayFieldComponent, CustomFieldComponent,
SelectFieldElement, ArrayFieldElement, CustomFieldElement, SelectOption,
// Select options union + resolution
SelectOptionsConfig, DataMapOptions, ResolverOptions, OptionsResolver,
ResolveSelectOptionsContext, UseSelectOptionsResult,
SchemaFactory, SchemaMap,
};
// Schema (configurable type → schema mapping)
export { buildFieldSchema, generateZodSchema, defaultSchemaMap, setSchemaMap, resetSchemaMap };
// Select options resolution
export { resolveSelectOptions, useSelectOptions };
// Utilities
export {
createVisibilityAwareResolver, calculateVisibility,
flattenFields, getFieldNames, mergeDefaults, getNestedValue, setNestedValue,
addJsonLogicOperation, applyJsonLogic, evaluateCondition,
isFieldElement, isContainerElement, isCustomFieldElement, isArrayFieldElement, isSectionContainer,
isStaticOptions, isDataMapOptions, isResolverOptions,
};Creating Field Components
All field components use BaseFieldComponent — the engine is type-agnostic:
import type { BaseFieldComponent } from 'rhf-dynamic-forms';
const TextField: BaseFieldComponent = ({ field, fieldState, config }) => (
<div className="field">
{config.label && <label htmlFor={field.name}>{config.label}</label>}
<input id={field.name} type="text" placeholder={config.placeholder} {...field} />
{fieldState.error && <span role="alert">{fieldState.error.message}</span>}
</div>
);For structurally-specific fields (select, array), cast config to access extra properties. For select, resolve the options union with the useSelectOptions hook (see Select Options):
import {
type BaseFieldComponent,
type SelectFieldElement,
useSelectOptions,
} from 'rhf-dynamic-forms';
const SelectField: BaseFieldComponent = ({ field, config: baseConfig }) => {
const config = baseConfig as SelectFieldElement;
const { options } = useSelectOptions(config, field.name);
return (
<select {...field}>
{options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
);
};Creating Container Components
import type { ContainerComponent, CustomContainerRegistry } from 'rhf-dynamic-forms';
const Row: ContainerComponent = ({ config, children }) => (
<div style={{ display: 'flex', gap: (config.meta?.gap as string) ?? '1rem', flexWrap: 'wrap' }}>
{children}
</div>
);
const Column: ContainerComponent = ({ config, children }) => (
<div style={{ width: (config.meta?.width as string) ?? 'auto' }}>{children}</div>
);
const Section: ContainerComponent = ({ config, children }) => (
<fieldset>
{config.meta?.title && <legend>{config.meta.title as string}</legend>}
<div>{children}</div>
</fieldset>
);
const containers: CustomContainerRegistry = { row: Row, column: Column, section: Section };Development
pnpm dev # Start dev server (localhost:3000)
pnpm build # Build library
pnpm test # Run tests
pnpm test:watch # Run tests in watch mode
pnpm typecheck # TypeScript type checking
pnpm lint # Check for lint errorsProject Structure
src/
├── components/ # FormRenderer, ElementRenderer, FieldRenderer, ContainerRenderer
├── context/ # React context
├── hooks/ # useDynamicFormContext
├── parser/ # Config parsing & validation
├── schema/ # Zod schema generation
├── resolver/ # Visibility-aware resolver
├── validation/ # JSON Logic evaluation
├── customComponents/ # Custom component utilities
├── types/ # TypeScript definitions
└── utils/ # Utilities
sample/ # Sample application
├── App.tsx # Demo form (wiring)
├── sampleFormConfig.ts # Sample form configuration
├── fields/ # Sample field components
└── containers/ # Sample containers (row, column, section)Tech Stack
- React 19 - UI framework
- react-hook-form - Form state management
- Zod v4 - Schema validation
- TypeScript - Type safety
- Vitest - Testing
- tsdown - Library bundling (ESM + CJS)
- Vite - Dev server
Contributing
Use Conventional Commits: type(scope): description
| Type | Purpose |
|------|---------|
| feat | New feature (minor bump) |
| fix | Bug fix (patch bump) |
| feat! / BREAKING CHANGE | Breaking change (major bump) |
| refactor, docs, test, chore | Non-release |
License
MIT
