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

raven-form-engine

v1.2.1

Published

Headless, adapter-agnostic React form engine. Zero UI/form-library dependencies — bring your own components.

Readme

Raven Form Engine

A headless, adapter-driven React form engine.
Bring your own UI components and form-state library — Raven handles the rest.

npm License: Apache-2.0 TypeScript

📖 Full documentation →


Installation

npm install raven-form-engine

How it works

Raven is built around two pluggable contracts:

| | Purpose | | ----------------- | --------------------------------------------------------------------------------- | | UIAdapter | Maps field types (text, select, checkbox, …) to your actual UI components | | FormAdapter | Connects a form-state library (React Hook Form, Formik, Zustand, …) to the engine |

Register them once on <FormProvider> — every <Form> and <WizardForm> in your tree inherits them automatically, with per-form override support.


Quick Start

1 — Create a UI adapter

import { createUIAdapter } from "raven-form-engine";

export const myUI = createUIAdapter({
  components: {
    text: ({
      name,
      value,
      onChange,
      onBlur,
      label,
      error,
      placeholder,
      type,
    }) => (
      <div>
        <label htmlFor={name}>{label}</label>
        <input
          id={name}
          type={type ?? "text"}
          value={(value as string) ?? ""}
          placeholder={placeholder}
          onChange={(e) => onChange(e.target.value)}
          onBlur={onBlur}
        />
        {error && <span className="error">{error}</span>}
      </div>
    ),

    select: ({ name, value, onChange, label, options, error }) => (
      <div>
        <label htmlFor={name}>{label}</label>
        <select
          id={name}
          value={(value as string) ?? ""}
          onChange={(e) => onChange(e.target.value)}
        >
          {options?.map((o) => (
            <option key={String(o.value)} value={String(o.value)}>
              {o.label}
            </option>
          ))}
        </select>
        {error && <span className="error">{error}</span>}
      </div>
    ),

    checkbox: ({ name, value, onChange, label }) => (
      <label>
        <input
          type="checkbox"
          id={name}
          checked={!!value}
          onChange={(e) => onChange(e.target.checked)}
        />
        {label}
      </label>
    ),
  },

  // Wraps each field with label + error chrome — optional but recommended
  FormItem: ({ label, error, description, required, children }) => (
    <div className="form-item">
      {label && (
        <label>
          {label}
          {required && " *"}
        </label>
      )}
      {children}
      {description && <p className="hint">{description}</p>}
      {error && <p className="error">{error}</p>}
    </div>
  ),

  // Catch-all rendered for any unregistered field type
  fallback: ({ name, label }) => (
    <p style={{ color: "red" }}>No component for "{label ?? name}"</p>
  ),

  // Field types that render inline — these skip FormItem wrapping
  // Defaults to ["checkbox", "switch"] if omitted
  inlineTypes: ["checkbox", "switch"],
});

Text-family fallbackemail, tel, url, number, password, time, and datetime automatically fall back to your text component with the correct HTML type prop injected. You only need to register them explicitly if you want a different component.


2 — Create a form adapter

import { createFormAdapter } from "raven-form-engine";
import {
  useForm,
  FormProvider,
  useController,
  useWatch,
  useFormContext,
} from "react-hook-form";

export const myFormAdapter = createFormAdapter({
  // Wraps the form tree — must call onSubmit on submission
  Provider({ onSubmit, defaultValues, children }) {
    const methods = useForm({ defaultValues });
    return (
      <FormProvider {...methods}>
        <form onSubmit={methods.handleSubmit(onSubmit)}>{children}</form>
      </FormProvider>
    );
  },

  // Returns live binding for a single field
  useField(name) {
    const { field, fieldState } = useController({ name });
    return {
      value: field.value,
      onChange: field.onChange,
      onBlur: field.onBlur,
      error: fieldState.error?.message,
      isDirty: fieldState.isDirty,
      isTouched: fieldState.isTouched,
    };
  },

  // Returns a trigger function for the submit button
  useSubmit() {
    return () => {}; // RHF handles submission via the form's onSubmit
  },

  // Returns a live snapshot of all (or specific) field values
  useWatch(names) {
    return useWatch({ name: names as string[] });
  },

  // Optional — lets WizardForm validate per-step before advancing
  useTrigger() {
    const { trigger } = useFormContext();
    return (names: string[]) => trigger(names);
  },
});

3 — Register globally

import { FormProvider } from "raven-form-engine";
import { myUI } from "./myUI";
import { myFormAdapter } from "./myFormAdapter";

export default function App() {
  return (
    <FormProvider uiAdapter={myUI} formAdapter={myFormAdapter}>
      <MyPage />
    </FormProvider>
  );
}

4 — Render a form

import { Form } from "raven-form-engine";

export default function ProfileForm() {
  return (
    <Form
      schema={[
        {
          name: "name",
          type: "text",
          label: "Full Name",
          validation: { required: "Required" },
        },
        {
          name: "email",
          type: "email",
          label: "Email",
          validation: { required: "Required" },
        },
        { name: "bio", type: "textarea", label: "Bio", colSpan: 12 },
        {
          name: "role",
          type: "select",
          label: "Role",
          options: [
            { label: "Admin", value: "admin" },
            { label: "User", value: "user" },
          ],
        },
        { name: "terms", type: "checkbox", label: "I accept the terms" },
      ]}
      onSubmit={(values) => console.log(values)}
      submitLabel="Save Profile"
    />
  );
}

Features

Wizard Forms

Multi-step forms with built-in step navigation. When useTrigger is implemented in your form adapter, each step is validated before advancing.

import { WizardForm } from "raven-form-engine";

<WizardForm
  steps={[
    {
      id: "personal",
      title: "Personal Info",
      description: "Tell us about yourself",
      fields: [
        { name: "firstName", type: "text", label: "First Name", colSpan: 6 },
        { name: "lastName", type: "text", label: "Last Name", colSpan: 6 },
        { name: "dob", type: "date", label: "Date of Birth" },
      ],
    },
    {
      id: "account",
      title: "Account",
      fields: [
        {
          name: "email",
          type: "email",
          label: "Email",
          validation: { required: true },
        },
        {
          name: "password",
          type: "password",
          label: "Password",
          validation: { required: true, minLength: 8 },
        },
      ],
    },
  ]}
  onSubmit={(values) => console.log(values)}
  submitLabel="Create Account"
/>;

Repeater Fields

Inline dynamic rows with add/remove controls.

{
  name: 'contacts',
  type: 'repeater',
  label: 'Contacts',
  repeaterConfig: {
    minRows: 1,
    maxRows: 5,
    addLabel: '+ Add',
    removeLabel: 'Remove',
    defaultRow: { name: '', phone: '' },
    fields: [
      { name: 'name',  type: 'text', label: 'Name',  colSpan: 6 },
      { name: 'phone', type: 'tel',  label: 'Phone', colSpan: 6 },
    ],
  },
}

Input Masking

Attach a mask pattern directly to any field using * for any character and # for digits.
The raw (unmasked) value is stored in form state; the display is masked.

{ name: 'phone', type: 'tel',  label: 'Phone', mask: '(***) ***-****' },
{ name: 'card',  type: 'text', label: 'Card',  mask: '**** **** **** ****' },
{ name: 'dob',   type: 'text', label: 'Date',  mask: '**/**/****' },

Use the mask engine directly when you need more control:

import { applyMask, removeMask, isMaskComplete } from "raven-form-engine";

const { masked, raw, complete } = applyMask(
  "4111111111111111",
  "**** **** **** ****",
);
// masked   → "4111 1111 1111 1111"
// raw      → "4111111111111111"
// complete → true

Or with the useMask hook in any input component:

import { useMask } from "raven-form-engine";

function DateInput() {
  const { value, onChange } = useMask({ pattern: "**/**/****" });
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}

Built-in named masks: maskPhone, maskBankCard, maskCurrency, maskPostalCode, maskOTP.
Register your own via maskRegistry.


Dynamic Fields

hidden and disabled accept either a static boolean or a function over live form values:

{
  name: 'companyName',
  type: 'text',
  label: 'Company Name',
  // Shown only when accountType is "business"
  hidden: values => values.accountType !== 'business',
},
{
  name: 'adminCode',
  type: 'text',
  label: 'Admin Code',
  // Editable only when role is "admin"
  disabled: values => values.role !== 'admin',
  // Re-evaluate when "role" changes
  dependsOn: ['role'],
},

Validation

Declarative, per-field validation with both synchronous and async support:

{
  name: 'username',
  type: 'text',
  label: 'Username',
  validation: {
    required: 'Username is required',
    minLength: 3,
    maxLength: 20,
    pattern: /^[a-z0-9_]+$/,
    custom: (value, allValues) =>
      allValues.role === 'admin' && value === 'admin' ? 'Reserved name' : null,
    asyncCustom: async (value) => {
      const taken = await checkUsername(value);
      return taken ? 'Already taken' : null;
    },
  },
}

Custom Field Render

Completely escape the engine for a specific field:

{
  name: 'avatar',
  type: 'custom',
  label: 'Profile Photo',
  render: ({ value, onChange, error }) => (
    <AvatarUploader src={value as string} onUpload={onChange} error={error} />
  ),
}

Value Formatting

Transform values between the display layer and stored form state:

import { formatCurrency, parseCurrency } from 'raven-form-engine';

{
  name: 'price',
  type: 'text',
  label: 'Price',
  formatter: formatCurrency,  // "1234" → displayed as "1,234"
  parser: parseCurrency,      // "1,234" → stored as 1234
}

Grid Layout

Fields use a 12-column grid by default. Control width with colSpan:

schema={[
  { name: 'firstName', type: 'text', label: 'First Name', colSpan: 6 },
  { name: 'lastName',  type: 'text', label: 'Last Name',  colSpan: 6 },
  { name: 'bio',       type: 'textarea', label: 'Bio',    colSpan: 12 },
]}

Dev Tools

Raven ships a floating DevTools panel for inspecting live form state during development.

Setup

Replace <FormProvider> with <RavenDevToolsProvider> and pass devTools:

import { RavenDevToolsProvider } from "raven-form-engine";

export default function RootLayout({ children }) {
  return (
    <RavenDevToolsProvider
      uiAdapter={myUI}
      formAdapter={myFormAdapter}
      devTools={process.env.NODE_ENV === "development"}
    >
      {children}
    </RavenDevToolsProvider>
  );
}

When devTools is true, a floating "Raven Dev" button appears in the bottom-right corner of the page. Nothing is rendered in production.

What's inside the panel

| Tab | Shows | | ---------- | --------------------------------------------------------------- | | Values | Live JSON tree of all field values — updates on every keystroke | | Errors | Active validation errors per field | | Dirty | Changed fields with smart analysis hints | | Meta | Submit count, submitting state, field counts, last update time |

Dirty field analysis goes beyond a simple diff — it categorises each change and surfaces actionable hints:

| Badge | Meaning | | --------------- | ------------------------------------------------------------------ | | SET | Field gained a value from an empty state | | CLEARED | Field was cleared — may be unintentional | | WHITESPACE | Value is whitespace-only — consider a trim formatter | | TYPE_COERCION | Value type changed (e.g. string → number) — likely a parser bug | | ZERO_VALUE | Numeric-looking field is 0 — mask/parser may be discarding input | | LONG_STRING | Unusually long string in state — check for base64 / rich text |

Each row is expandable with the raw initial → current values and the full hint explanation.

Build your own DevTools integration

The DevTools context is fully exported for advanced usage:

import { useDevTools, DevToolsContext } from "raven-form-engine";

function MyCustomInspector() {
  const { forms, enabled } = useDevTools();

  if (!enabled) return null;

  return (
    <ul>
      {Array.from(forms.values()).map((snap) => (
        <li key={snap.id}>
          {snap.label} — {Object.keys(snap.values).length} fields
        </li>
      ))}
    </ul>
  );
}

Per-Form Override

Global adapters can be shadowed on any individual form:

<Form
  schema={schema}
  adapter={specialFormAdapter}
  ui={specialUIAdapter}
  onSubmit={onSubmit}
/>

Advanced — Rendering Pipeline

For adapter authors who need to intercept component resolution:

import {
  resolveFieldComponent,
  buildFieldProps,
  isInlineType,
} from "raven-form-engine";

// Resolve what component would be used for a given field type
const Component = resolveFieldComponent("email", myUI);

// Build the full props object that gets passed to the component
const props = buildFieldProps(field, { value, onChange, onBlur, error });

// Check whether a field type is rendered inline (skips FormItem wrapping)
const inline = isInlineType("checkbox", myUI); // → true

TypeScript

All public contracts are exported:

import type {
  FormProps,
  WizardFormProps,
  FormAdapter,
  FormAdapterProviderProps,
  UIAdapter,
  UIFieldProps,
  UIFormItemProps,
  FormField,
  FieldValidation,
  FieldBinding,
  FieldRenderContext,
  FormSchema,
  WizardStep,
  RepeaterConfig,
  FieldType,
  AdapterRegistry,
} from "raven-form-engine";

License

Apache-2.0 © Raven Form Engine