raven-form-engine
v1.2.1
Published
Headless, adapter-agnostic React form engine. Zero UI/form-library dependencies — bring your own components.
Maintainers
Readme
Raven Form Engine
A headless, adapter-driven React form engine.
Bring your own UI components and form-state library — Raven handles the rest.
Installation
npm install raven-form-engineHow 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 fallback —
tel,url,number,password,time, anddatetimeautomatically fall back to yourtextcomponent with the correct HTMLtypeprop 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 → trueOr 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); // → trueTypeScript
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
