npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@stormsw/dynaform

v0.1.0

Published

Generate React forms from declarative YAML/JSON schemas

Downloads

26

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 filled

Contents


Installation

npm install dynaform

Peer dependencies — install these if you haven't already:

npm install react react-dom

Quick 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: 120

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

Fields 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 component

Built-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: Mexico

Each 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 }); // true

filterByVisibility(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 examples

Architecture

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-onlyvisibleIf on 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 visibleIf dependencies are rejected at compile time, not at runtime, so infinite render loops are impossible.
  • Targeted subscriptionsDynaField calls useWatch only on the fields referenced in its own visibleIf rule (and its options' rules), not on the whole form.

License

MIT