rhf-dynamic-forms
v1.8.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>
),
select: ({ field, config }) => (
<div>
<label>{config.label}</label>
<select {...field}>
{config.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)
}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]" } }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",
},
}Available JSON Logic Operations:
- Standard:
var,and,or,!,==,!=,>,<,>=,<=,if - Custom:
regex_match-["pattern", { var: "fieldName" }]
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>;
}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
}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,
SelectFieldComponent, ArrayFieldComponent, CustomFieldComponent,
SelectFieldElement, ArrayFieldElement, CustomFieldElement, SelectOption,
SchemaFactory, SchemaMap,
};
// Schema (configurable type → schema mapping)
export { buildFieldSchema, generateZodSchema, defaultSchemaMap, setSchemaMap, resetSchemaMap };
// Utilities
export {
createVisibilityAwareResolver, calculateVisibility,
flattenFields, getFieldNames, mergeDefaults, getNestedValue, setNestedValue,
applyJsonLogic, evaluateCondition,
isFieldElement, isContainerElement, isCustomFieldElement, isArrayFieldElement, isSectionContainer,
};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:
import type { BaseFieldComponent, SelectFieldElement } from 'rhf-dynamic-forms';
const SelectField: BaseFieldComponent = ({ field, fieldState, config: baseConfig }) => {
const config = baseConfig as SelectFieldElement;
return (
<select {...field}>
{config.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
