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

@ashios15/react-dynamic-form-engine

v1.1.0

Published

Headless, config-driven multi-step form engine for React 19 with a pure static analyzer (form-lint CLI) that catches config bugs at build time.

Readme

React Dynamic Form Engine

npm version CI Tests License: MIT

Headless, config-driven multi-step forms for React 19 — powered by React Hook Form and Zod, with a built-in static analyzer (form-lint) that catches broken field references, type drift, and structural mistakes at build time, not in production.

Table of Contents

Install

npm install @ashios15/react-dynamic-form-engine react-hook-form zod @hookform/resolvers

Peer dependencies: react ^19, react-dom ^19, react-hook-form ^7.54, zod ^3.23, @hookform/resolvers ^3.9.

Quick Start

The simplest possible form — one step, two fields, Zod validation:

import { DynamicForm, type FormConfig } from '@ashios15/react-dynamic-form-engine';
import { z } from 'zod';

const config: FormConfig = {
  id: 'contact',
  steps: [
    {
      id: 'main',
      title: 'Contact Us',
      fields: [
        {
          name: 'email',
          label: 'Email address',
          type: 'email',
          required: true,
          validation: z.string().email('Enter a valid email'),
        },
        {
          name: 'message',
          label: 'Message',
          type: 'textarea',
          required: true,
          validation: z.string().min(10, 'At least 10 characters'),
        },
      ],
    },
  ],
  onSubmit: async (data) => {
    await fetch('/api/contact', { method: 'POST', body: JSON.stringify(data) });
  },
};

export function ContactForm() {
  return <DynamicForm config={config} />;
}

Multi-Step Wizard

Split a long form across multiple steps. Each step validates independently before advancing:

import { DynamicForm, type FormConfig } from '@ashios15/react-dynamic-form-engine';
import { z } from 'zod';

const onboardingConfig: FormConfig = {
  id: 'onboarding',
  defaultValues: { plan: 'starter' },
  steps: [
    {
      id: 'account',
      title: 'Create your account',
      description: 'Step 1 of 3',
      fields: [
        {
          name: 'email',
          label: 'Work email',
          type: 'email',
          required: true,
          validation: z.string().email(),
        },
        {
          name: 'password',
          label: 'Password',
          type: 'password',
          required: true,
          validation: z.string().min(8, 'Minimum 8 characters'),
        },
      ],
    },
    {
      id: 'profile',
      title: 'Tell us about yourself',
      description: 'Step 2 of 3',
      fields: [
        {
          name: 'firstName',
          label: 'First name',
          type: 'text',
          required: true,
          colSpan: 6,
        },
        {
          name: 'lastName',
          label: 'Last name',
          type: 'text',
          required: true,
          colSpan: 6,
        },
        {
          name: 'role',
          label: 'Your role',
          type: 'select',
          options: [
            { label: 'Engineer', value: 'eng' },
            { label: 'Designer', value: 'design' },
            { label: 'Product', value: 'pm' },
            { label: 'Other', value: 'other' },
          ],
        },
      ],
    },
    {
      id: 'plan',
      title: 'Choose a plan',
      description: 'Step 3 of 3',
      fields: [
        {
          name: 'plan',
          label: 'Plan',
          type: 'radio',
          options: [
            { label: 'Starter — free', value: 'starter' },
            { label: 'Pro — $12/mo', value: 'pro' },
            { label: 'Enterprise', value: 'enterprise' },
          ],
        },
        {
          name: 'teamSize',
          label: 'Team size',
          type: 'number',
          // Only visible when Enterprise is selected
          conditions: [{ when: 'plan', operator: 'equals', value: 'enterprise' }],
        },
      ],
    },
  ],
  onStepChange: (step, data) => {
    console.log(`Moved to step ${step}`, data);
  },
  onSubmit: async (data) => {
    await fetch('/api/onboard', { method: 'POST', body: JSON.stringify(data) });
  },
};

export function OnboardingWizard() {
  return <DynamicForm config={onboardingConfig} showProgress showPreview />;
}

Headless Mode — Full UI Control

Pass a render function as children to take complete ownership of the UI. The engine still handles state, navigation, validation, and conditions:

import { DynamicForm, DynamicField, type FormConfig } from '@ashios15/react-dynamic-form-engine';

export function CustomWizard({ config }: { config: FormConfig }) {
  return (
    <DynamicForm config={config}>
      {({ state, actions, currentFields, currentStepConfig, registerField }) => (
        <div className="wizard">
          {/* Your own step indicator */}
          <nav className="wizard__steps">
            {Array.from({ length: state.totalSteps }, (_, i) => (
              <span
                key={i}
                className={i === state.currentStep ? 'active' : i < state.currentStep ? 'done' : ''}
              >
                {i + 1}
              </span>
            ))}
          </nav>

          <h2>{currentStepConfig.title}</h2>

          {/* Render each visible field */}
          {currentFields.map((field) => (
            <DynamicField
              key={field.name}
              field={field}
              registration={registerField(field.name)}
              error={state.errors[field.name]}
              value={state.values[field.name]}
              onChange={(v) => actions.setValue(field.name, v)}
            />
          ))}

          {/* Error summary */}
          {Object.keys(state.errors).length > 0 && (
            <ul className="errors">
              {Object.entries(state.errors).map(([name, msg]) => (
                <li key={name}>{msg}</li>
              ))}
            </ul>
          )}

          <div className="wizard__actions">
            {!state.isFirstStep && (
              <button onClick={actions.prevStep}>Back</button>
            )}
            <button
              onClick={state.isLastStep ? actions.submit : actions.nextStep}
              disabled={state.isSubmitting}
            >
              {state.isLastStep ? (state.isSubmitting ? 'Submitting…' : 'Submit') : 'Continue'}
            </button>
          </div>
        </div>
      )}
    </DynamicForm>
  );
}

Custom Field Types

Use type: 'custom' with a render function to embed any component inside the engine's validation and step flow:

import { type FormConfig } from '@ashios15/react-dynamic-form-engine';
import { CountryPicker } from './CountryPicker';
import { RichTextEditor } from './RichTextEditor';

const config: FormConfig = {
  id: 'application',
  steps: [
    {
      id: 'details',
      title: 'Application details',
      fields: [
        {
          name: 'country',
          label: 'Country',
          type: 'custom',
          required: true,
          render: ({ value, onChange, error }) => (
            <div>
              <CountryPicker value={value as string} onChange={onChange} />
              {error && <p className="field-error">{error}</p>}
            </div>
          ),
        },
        {
          name: 'coverLetter',
          label: 'Cover letter',
          type: 'custom',
          render: ({ value, onChange }) => (
            <RichTextEditor
              initialValue={value as string}
              onChange={onChange}
            />
          ),
        },
      ],
    },
  ],
  onSubmit: (data) => console.log(data),
};

useFormEngine Hook

Use the hook directly when you need access to the React Hook Form instance (form) or want to build a fully custom wrapper without <DynamicForm>:

import { useFormEngine, type FormConfig } from '@ashios15/react-dynamic-form-engine';
import { z } from 'zod';

const config: FormConfig = {
  id: 'settings',
  steps: [
    {
      id: 'notifications',
      title: 'Notifications',
      fields: [
        { name: 'emailDigest', label: 'Email digest', type: 'checkbox' },
        {
          name: 'digestFrequency',
          label: 'How often',
          type: 'select',
          options: [
            { label: 'Daily', value: 'daily' },
            { label: 'Weekly', value: 'weekly' },
          ],
          conditions: [{ when: 'emailDigest', operator: 'equals', value: true }],
        },
        {
          name: 'slackWebhook',
          label: 'Slack webhook URL',
          type: 'text',
          validation: z.string().url().optional(),
        },
      ],
    },
  ],
  onSubmit: async (data) => {
    await fetch('/api/settings', { method: 'PATCH', body: JSON.stringify(data) });
  },
};

export function SettingsForm() {
  const { state, actions, currentFields, form } = useFormEngine(config);

  // Access react-hook-form directly for watch, formState, etc.
  const watchedValues = form.watch();

  return (
    <form onSubmit={(e) => { e.preventDefault(); actions.submit(); }}>
      {currentFields.map((field) => (
        <div key={field.name}>
          <label htmlFor={field.name}>{field.label}</label>
          <input id={field.name} type={field.type} {...form.register(field.name)} />
          {state.errors[field.name] && (
            <span role="alert">{state.errors[field.name]}</span>
          )}
        </div>
      ))}
      <button type="submit" disabled={state.isSubmitting}>Save</button>

      {/* Debug panel during development */}
      {process.env.NODE_ENV === 'development' && (
        <pre>{JSON.stringify(watchedValues, null, 2)}</pre>
      )}
    </form>
  );
}

Conditional Fields

Fields are shown or hidden based on the live values of other fields in the form. All evaluation is pure and synchronous — no effects, no timers.

{
  name: 'companyName',
  label: 'Company name',
  type: 'text',
  conditions: [
    // Both conditions must pass (AND logic)
    { when: 'accountType', operator: 'equals', value: 'business' },
    { when: 'country', operator: 'in', value: ['US', 'CA', 'GB'] },
  ],
}

Supported operators:

| Operator | Matches when | |---|---| | equals | fieldValue === value | | not_equals | fieldValue !== value | | contains | string field contains value as substring | | gt | numeric field > value | | lt | numeric field < value | | in | value is an array and contains fieldValue |

Static Analyzer — form-lint

Config bugs — dangling when references, select fields with no options, required fields hidden by their own conditions — don't produce TypeScript errors. They surface silently at runtime, often after submission.

form-lint checks 16 rules statically. It's pure TypeScript, no React, no DOM, safe in CI.

# Pipe a JSON config
cat forms/checkout.json | npx form-lint

# Pass a file path
npx form-lint forms/checkout.json

# JSON output for programmatic consumption
npx form-lint forms/checkout.json --json

Example output:

$ npx form-lint forms/checkout.json

Found 2 errors and 1 warning.

  ERROR  steps[1].fields[3].conditions[0].when  [condition-unknown-field]
         Field "billing_same" has a condition watching "useSameBilling", which does not exist in any step.
  ERROR  steps[0].fields[2].options  [select-without-options]
         Field "country" has type "select" but no options.
  warn   steps[1].fields[0]  [required-hidden-by-own-condition]
         Field "company" is required but hidden by conditions — validation only runs when visible.

Exit 0 — no errors (warnings allowed). Exit 2 — at least one error.

Programmatic API (also catches Zod schema/type mismatch, since schemas survive in TypeScript):

import { lint, formatReport } from '@ashios15/react-dynamic-form-engine/lint';
import { myFormConfig } from './forms/checkout';

const report = lint(myFormConfig);

if (!report.ok) {
  console.error(formatReport(report));
  // report.issues is typed: Array<{ rule, severity, message, location }>
  const errors = report.issues.filter((i) => i.severity === 'error');
  process.exit(1);
}

JSON output for integration with editors, CI dashboards, or custom reporters:

{
  "ok": false,
  "errors": 2,
  "warnings": 1,
  "issues": [
    {
      "rule": "condition-unknown-field",
      "severity": "error",
      "message": "Field \"billing_same\" has a condition watching \"useSameBilling\", which does not exist in any step.",
      "location": { "path": "steps[1].fields[3].conditions[0].when", "stepIndex": 1, "field": "billing_same" }
    }
  ]
}

Lint rules

| Rule | Severity | What it catches | |---|---|---| | empty-steps | error | Config has no steps | | empty-step-fields | warning | A step has no fields | | duplicate-step-id | error | Two steps share the same id | | duplicate-field-name | error | Same field name in multiple steps | | select-without-options | error | select or radio with no options | | duplicate-option-value | error | Duplicate value in a field's options | | options-on-non-choice-field | warning | options on a non-choice field (ignored at runtime) | | custom-without-render | error | type: 'custom' with no render function | | invalid-col-span | error | colSpan outside [1, 12] | | condition-unknown-field | error | when references a field that doesn't exist | | condition-self-reference | error | A field's condition watches itself | | condition-forward-reference | warning | Field in step N depends on a field in step M > N | | condition-in-non-array-value | error | Operator in used with a non-array value | | condition-operator-type-mismatch | warning | Numeric operator against a non-numeric field, or contains against a non-string | | required-hidden-by-own-condition | warning | required field is conditionally hidden | | validation-type-mismatch | warning | Zod schema kind doesn't match field type |

CI integration

# .github/workflows/ci.yml
- run: npm ci
- run: npm test                         # vitest — 30 unit tests
- run: cat forms/*.json | npx form-lint # catch config bugs before deploy

API Reference

<DynamicForm>

| Prop | Type | Default | Description | |---|---|---|---| | config | FormConfig | — | Form configuration | | children | (props: RenderProps) => ReactNode | — | Headless render prop — omit for default UI | | showProgress | boolean | true | Show step indicator | | showPreview | boolean | false | Show live value preview panel | | className | string | '' | Class applied to the outer container |

useFormEngine(config)

const {
  state,        // FormEngineState — currentStep, totalSteps, isLastStep, isSubmitting, errors, values, completedSteps
  actions,      // FormEngineActions — nextStep, prevStep, goToStep, submit, reset, setValue, clearErrors
  currentFields,      // FieldConfig[] — visible fields for the current step (conditions already applied)
  currentStepConfig,  // StepConfig — the raw step definition
  registerField,      // (name: string) => react-hook-form register result
  form,         // UseFormReturn — direct access to react-hook-form instance
} = useFormEngine(config);

FormConfig

interface FormConfig {
  id: string;
  steps: StepConfig[];
  defaultValues?: Record<string, unknown>;
  onSubmit: (data: Record<string, unknown>) => void | Promise<void>;
  onStepChange?: (step: number, data: Record<string, unknown>) => void;
  i18nNamespace?: string;
}

FieldConfig

interface FieldConfig {
  name: string;
  label: string;
  type: 'text' | 'email' | 'password' | 'number' | 'textarea' | 'select' | 'radio' | 'checkbox' | 'date' | 'file' | 'custom';
  placeholder?: string;
  defaultValue?: unknown;
  helpText?: string;
  options?: Array<{ label: string; value: string; disabled?: boolean }>;
  validation?: z.ZodTypeAny;
  conditions?: ConditionalRule[];
  required?: boolean;
  render?: (props: CustomFieldProps) => React.ReactNode; // for type: 'custom'
  colSpan?: number; // 1–12 grid column span
}

Architecture

┌─────────────────────────────────────────┐
│           FormConfig (JSON/TS)          │
├─────────────────────────────────────────┤
│                                         │
│   useFormEngine (hook)                  │
│   ├── React Hook Form (validation)      │
│   ├── Zod (schema per step)             │
│   ├── Condition engine (show/hide)      │
│   └── Step state machine                │
│                                         │
├──────────────┬──────────────────────────┤
│  Default UI  │   Headless (render prop) │
│  ├── Field   │   → Full control         │
│  ├── Steps   │   → Your components      │
│  └── Preview │   → Your styling         │
└──────────────┴──────────────────────────┘

form-lint (pure TS, no React)
└── lint(config) → LintReport
    └── 16 rules: structure, conditions, types, Zod schema

Development

npm install
npm run dev          # Storybook on http://localhost:6006
npm test             # Vitest (30 tests)
npm run build        # tsup dual ESM/CJS + CLI bin
npm run form-lint    # run form-lint via tsx without a build step

License

MIT © ashios15