@stormsw/dynaform
v0.1.0
Published
Generate React forms from declarative YAML/JSON schemas
Downloads
26
Maintainers
Readme
dynaform
Generate React forms from declarative YAML/JSON schemas. Write a schema once — dynaform compiles it into a fully validated, reactive form with zero boilerplate.
form:
id: contact
fields:
- name: email
type: text
ui: { label: Email, widget: input }
validation: { required: true, pattern: "^[^@]+@[^@]+$" }
- name: message
type: text
ui: { label: Message, widget: textarea }
validation: { required: true, minLength: 10 }
logic:
visibleIf: { "!!": [{ var: email }] } # appears only after email is filledContents
- Installation
- Quick start
- Schema reference
- Component registry
- API reference
- Dynamic schemas — injecting runtime data
- Server-side validation
- Examples
- Architecture
Installation
npm install dynaformPeer dependencies — install these if you haven't already:
npm install react react-domQuick start
1. Register your UI widgets
dynaform is headless — it renders through components you provide. Map DSL widget names to your React components once per app:
import { createRegistry } from 'dynaform';
const registry = createRegistry()
.register('input', InputWidget)
.register('textarea', TextareaWidget)
.register('select', SelectWidget)
.register('checkbox', CheckboxWidget)
.register('number', NumberWidget)
.register('datepicker', DatepickerWidget);2. Write a schema
# schema.yaml
form:
id: user_profile
title: User Profile
fields:
- name: full_name
type: text
ui:
label: Full Name
widget: input
placeholder: "Jane Smith"
validation:
required: true
minLength: 2
maxLength: 80
- name: age
type: integer
ui:
label: Age
widget: number
validation:
required: true
min: 18
max: 1203. Compile and render
import { compileSchema, DynaForm } from 'dynaform';
import schemaYaml from './schema.yaml?raw'; // Vite; adjust for your bundler
const schema = compileSchema(schemaYaml);
function MyForm() {
return (
<DynaForm
schema={schema}
registry={registry}
onSubmit={(data) => console.log(data)}
>
<button type="submit">Submit</button>
</DynaForm>
);
}Schema reference
Field types
| DSL type | Zod primitive | Notes |
|-------------|--------------------------|------------------------------------------------|
| text | z.string() | Supports minLength, maxLength, pattern |
| integer | z.number().int() | Rejects floats; supports min, max |
| decimal | z.number() | Supports min, max, multipleOf |
| bool | z.boolean() | Coerces HTML checkbox strings automatically |
| datetime | z.string().date() / z.string().datetime() / z.date() | Accepts YYYY-MM-DD, full ISO 8601, or JS Date |
| lists | z.array(...) | Supports minItems, maxItems, uniqueItems |
Validation
All constraint keys live under validation::
validation:
required: true # field must be non-empty
minLength: 2 # text: minimum character count
maxLength: 200 # text: maximum character count
pattern: "^[A-Z]" # text: regex (string, not /regex/)
min: 0 # integer / decimal: minimum value
max: 100 # integer / decimal: maximum value
multipleOf: 0.01 # decimal: precision constraint (e.g. currency)
minItems: 1 # lists: minimum array length
maxItems: 10 # lists: maximum array length
uniqueItems: true # lists: no duplicate entriesFields without required: true are optional — their Zod type is automatically wrapped in .optional().
Hidden fields (whose visibleIf rule evaluates to false) are also automatically made optional so validation never fires on invisible inputs.
UI hints
ui:
label: "Human-readable label" # required
widget: input # required — see widget table below
placeholder: "Hint text" # optional
helpText: "Shown below field" # optional
className: "my-css-class" # optional — forwarded to the widget componentBuilt-in widget identifiers
| Identifier | Typical component |
|---------------|-------------------------|
| input | <input type="text"> |
| textarea | <textarea> |
| select | <select> |
| multiselect | Multi-select control |
| checkbox | <input type="checkbox"> |
| datepicker | Date picker |
| number | <input type="number"> |
Custom widget names are fully supported — register any string with registry.register('my-widget', MyComponent).
Conditional visibility
Attach a logic.visibleIf key to any field. The value is a JsonLogic rule; {"var": "fieldName"} references another field's current value.
# Show a text area only when the user ticks the checkbox
- name: comments
type: text
ui: { label: Comments, widget: textarea }
logic:
visibleIf: { "==": [{ var: wants_feedback }, true] }Common JsonLogic patterns
| Goal | Rule |
|------|------|
| Field has any value | { "!!": [{ "var": "field" }] } |
| Field equals value | { "==": [{ "var": "field" }, "value"] } |
| Field does not equal value | { "!=": [{ "var": "field" }, "value"] } |
| Numeric comparison | { ">=": [{ "var": "score" }, 8] } |
| Value in a set | { "in": [{ "var": "city" }, ["paris", "london"]] } |
| AND of conditions | { "and": [ rule1, rule2 ] } |
| OR of conditions | { "or": [ rule1, rule2 ] } |
The compiler performs a topological sort (DAG) over all visibleIf rules at compile time and throws CyclicDependencyError if a circular dependency is detected.
Select options & option filtering
Options for select and multiselect fields are declared inline:
- name: country
type: text
ui: { label: Country, widget: select }
validation: { required: true }
options:
- value: us
label: United States
- value: ca
label: Canada
- value: mx
label: MexicoEach option can carry its own logic.visibleIf rule to be filtered out of the rendered list at runtime. The Zod validation schema is never mutated — only the presented list shrinks:
options:
- value: overnight
label: "Overnight (next business day)"
logic:
# Only available in hub cities
visibleIf:
in:
- var: city
- [paris, london, new_york, tokyo]Exclusive select — preventing the same value from being picked twice:
# secondary must differ from primary
options:
- value: sales
label: Sales
logic:
visibleIf: { "!=": [{ var: primary_dimension }, "sales"] }
- value: marketing
label: Marketing
logic:
visibleIf: { "!=": [{ var: primary_dimension }, "marketing"] }Multi-step forms
Replace fields with steps. Each step can carry a nextStepIf routing rule:
form:
id: onboarding
steps:
- id: personal
title: Personal Details
fields:
- name: full_name
type: text
ui: { label: Full Name, widget: input }
validation: { required: true }
- name: account_type
type: text
ui: { label: Account Type, widget: select }
options:
- { value: personal, label: Personal }
- { value: business, label: Business }
nextStepIf: { "==": [{ var: account_type }, "business"] }
- id: business_details
title: Business Details
fields:
- name: company_name
type: text
ui: { label: Company Name, widget: input }
validation: { required: true }Component registry
The registry maps DSL widget names to your React components. Every widget receives a standard set of props defined by WidgetComponentProps<T>:
interface WidgetComponentProps<T = unknown> {
name: string; // field name (from schema)
label: string; // from ui.label
value: T; // current field value
onChange: (v: T) => void;
onBlur: () => void;
error?: string; // validation error message, if any
placeholder?: string;
helpText?: string;
disabled?: boolean;
className?: string;
// select / multiselect also receive:
options?: { value: unknown; label: string }[];
}Minimal widget example
import type { WidgetComponentProps } from 'dynaform';
function InputWidget({ name, label, value, onChange, onBlur, error, placeholder }: WidgetComponentProps<string>) {
return (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
value={value ?? ''}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
/>
{error && <span role="alert">{error}</span>}
</div>
);
}Select widget — options & option filtering are handled for you
function SelectWidget({ name, label, value, onChange, onBlur, error, options = [] }: WidgetComponentProps<string>) {
return (
<div>
<label htmlFor={name}>{label}</label>
<select id={name} value={value ?? ''} onChange={(e) => onChange(e.target.value)} onBlur={onBlur}>
<option value="">— select —</option>
{options.map((opt) => (
<option key={String(opt.value)} value={String(opt.value)}>{opt.label}</option>
))}
</select>
{error && <span role="alert">{error}</span>}
</div>
);
}The engine evaluates all logic.visibleIf option rules and passes only the currently visible options — your widget just renders what it receives.
API reference
compileSchema(yaml: string): CompiledSchema
Parses a YAML string, validates the DSL structure, detects cyclic dependencies, and returns a CompiledSchema. Call once per schema (e.g. in useMemo). Throws on invalid YAML or cyclic visibleIf rules.
const schema = compileSchema(yamlString);buildZodSchema(form, formValues?): z.ZodObject
Builds a runtime Zod validation schema. Hidden fields are wrapped in .optional(). Used internally by DynaForm / useDynaForm; also useful for server-side validation.
import { buildZodSchema } from 'dynaform';
const zodSchema = buildZodSchema(schema.form, currentFormValues);
zodSchema.parse(formData); // throws ZodError on failure<DynaForm>
Root form component. Renders all fields via the registry, wires Zod validation, and provides a React Hook Form context.
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| schema | CompiledSchema | ✓ | Output of compileSchema() |
| registry | ComponentRegistry | ✓ | Widget map |
| onSubmit | (data) => void | ✓ | Called with validated form data on submit |
| defaultValues | Record<string, unknown> | | Pre-fill field values |
| className | string | | CSS class for the <form> element |
| children | ReactNode | | Rendered after all fields (e.g. a submit button) |
createRegistry(): ComponentRegistry
const registry = createRegistry()
.register('input', InputWidget)
.register('select', SelectWidget);registry.register(widget, Component): this
Chainable. Accepts any string as the widget name, not just the built-in DslWidget identifiers.
registry.resolve(widget): ComponentType
Returns the registered component. Throws if the widget is not registered.
useDynaForm(options): { methods, zodSchema }
Lower-level hook if you need direct access to React Hook Form's methods (e.g. to call methods.setValue imperatively or build a custom form shell).
const { methods, zodSchema } = useDynaForm({ schema, defaultValues });evaluateRule(rule, data): boolean
Evaluates a single JsonLogic rule against a data object. Useful in custom widget logic or server-side rule checks.
import { evaluateRule } from 'dynaform';
const isVisible = evaluateRule({ ">=": [{ var: "score" }, 8] }, { score: 10 }); // truefilterByVisibility(items, data): T[]
Filters any array of objects that carry a logic.visibleIf key. Used internally by the engine; re-exported for custom widget use.
const visibleOptions = filterByVisibility(field.options, formValues);CyclicDependencyError
Thrown by compileSchema when a circular dependency is found in visibleIf rules.
import { CyclicDependencyError } from 'dynaform';
try {
compileSchema(yaml);
} catch (e) {
if (e instanceof CyclicDependencyError) {
console.error('Cycle involves:', e.involvedFields);
}
}Dynamic schemas — injecting runtime data
When option lists come from runtime data (e.g. columns discovered from a datastream), build the DslDocument object in TypeScript and serialize it to YAML before compiling:
import { dump as toYaml } from 'js-yaml';
import { compileSchema } from 'dynaform';
import type { DslDocument, DslOption } from 'dynaform';
interface Column { value: string; label: string; }
function buildSchema(columns: Column[]): ReturnType<typeof compileSchema> {
// Primary: all columns available, no filtering
const primaryOptions: DslOption[] = columns.map((c) => ({
value: c.value,
label: c.label,
}));
// Secondary: each option hides itself when already chosen as primary
const secondaryOptions: DslOption[] = columns.map((c) => ({
value: c.value,
label: c.label,
logic: {
visibleIf: { '!=': [{ var: 'primary_dimension' }, c.value] },
},
}));
const doc: DslDocument = {
form: {
id: 'analysis_params',
fields: [
{
name: 'primary_dimension',
type: 'text',
ui: { label: 'Primary Dimension', widget: 'select' },
validation: { required: true },
options: primaryOptions,
},
{
name: 'secondary_dimension',
type: 'text',
ui: { label: 'Secondary Dimension', widget: 'select' },
validation: { required: true },
options: secondaryOptions,
},
],
},
};
return compileSchema(toYaml(doc));
}In React, rebuild the schema with useMemo whenever the data changes:
const schema = useMemo(() => buildSchema(columns), [columns]);Server-side validation
The same YAML schema can be used in Node.js to validate incoming payloads — identical rules, no duplication:
import { compileSchema, buildZodSchema } from 'dynaform';
import { readFileSync } from 'fs';
const yaml = readFileSync('./schema.yaml', 'utf8');
const { form } = compileSchema(yaml);
// In your API handler:
app.post('/submit', (req, res) => {
const zodSchema = buildZodSchema(form, req.body);
const result = zodSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({ errors: result.error.flatten() });
}
// result.data is fully typed and validated
processSubmission(result.data);
res.json({ ok: true });
});Examples
| Example | Demonstrates |
|---------|-------------|
| examples/conditional-fields | Field-level visibleIf gates, boolean checkboxes, cascading dependent fields |
| examples/cascading-select | 3-level cascading selects (region → country → city) with option-level filtering |
| examples/exclusive-select | Shared pool of options across multiple selects — each selection removes the picked value from other dropdowns |
| examples/dynamic-options | Runtime data injection — options built from a datastream; exclusive-select logic generated programmatically |
Run all examples locally:
npm run examplesArchitecture
YAML/JSON Schema (DSL)
│
▼
┌─────────────────────┐
│ Schema Compiler │ Parses YAML → validates DSL → builds dependency DAG
│ compileSchema() │ Topological sort; throws CyclicDependencyError on cycles
└─────────┬───────────┘
│ CompiledSchema { form, fieldOrder, dependencies }
▼
┌─────────────────────┐
│ useDynaForm hook │ Wires CompiledSchema to React Hook Form + Zod resolver
│ │ useWatch on dependency fields only → O(1) renders
│ buildZodSchema() │ Rebuilds Zod schema on every change; hidden fields → optional
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ DynaForm / DynaField │ Renders fields in topo-sort order
│ ast-evaluator │ json-logic-js evaluates visibleIf rules — never eval()
│ ComponentRegistry │ Maps DSL widget strings → your React components
└─────────────────────┘Key design decisions
- Headless — no bundled UI components; works with any design system (MUI, shadcn, plain HTML).
- No schema mutation — the Zod schema is rebuilt via a factory function; it is never mutated in place, which prevents stale-closure bugs and enables symmetrical server-side use.
- Option filtering is presentation-only —
visibleIfon options never changes the Zod schema; it only trims the list passed to the widget. This avoids validation errors when a hidden option was previously selected. - DAG enforcement — cyclic
visibleIfdependencies are rejected at compile time, not at runtime, so infinite render loops are impossible. - Targeted subscriptions —
DynaFieldcallsuseWatchonly on the fields referenced in its ownvisibleIfrule (and its options' rules), not on the whole form.
License
MIT
