npm.io
0.1.2 • Published 3d ago

@easy-forms/core

Licence
MIT
Version
0.1.2
Deps
0
Vulns
0
Weekly
0
Stars
6

easy-forms

@easy-forms/core

The React form engine you'd otherwise build yourself.
Hand it an array of question objects and it owns rendering, validation, conditional logic, dirty/touched tracking, multi-step wizards, and submission. The schema is the source of truth — no useForm, no register(), no Controller.

npm version license types

Documentation · Quick start · Examples · GitHub

Install

npm install @easy-forms/core

@easy-forms/core is headless. The default UI ships as an own-the-code shadcn registry — you pull the renderers into your project with the shadcn CLI and they live in your repo, fully editable (no black-box UI package). In a shadcn project, add the @easy-forms namespace to your components.json:

{
  "registries": {
    "@easy-forms": "https://chandima301.github.io/easy-forms/r/{name}.json"
  }
}

Then add the components:

# the whole kit: every renderer + a pre-wired <EasyForm> wrapper
pnpm dlx shadcn@latest add @easy-forms/easy-form

# …or just the controls you need
pnpm dlx shadcn@latest add @easy-forms/text @easy-forms/select

This copies the renderers into @/components/easy-forms/ and pulls the canonical shadcn primitives they use (input, select, …) into @/components/ui/. Want a different UI entirely? Skip the registry and pass your own registry to <Form> (see Renderer registry).

Hello world

import type { FormSchema } from '@easy-forms/core';
import { EasyForm } from '@/components/easy-forms/easy-form';

interface Data extends Record<string, unknown> {
  email: string;
  password: string;
}

const schema: FormSchema<Data> = {
  title: 'Sign in',
  groups: [
    {
      questions: [
        { key: 'email', label: 'Email', control: 'email',
          validators: { required: true, email: true } },
        { key: 'password', label: 'Password', control: 'text', inputType: 'password',
          validators: { required: true, minLength: 8 } },
      ],
    },
  ],
};

export function App() {
  return (
    <EasyForm<Data>
      schema={schema}
      initialValues={{ email: '', password: '' }}
      onSubmit={async (values) => { await fetch('/login', { method: 'POST', body: JSON.stringify(values) }); }}
    />
  );
}

<EasyForm> is the pre-wired wrapper the registry scaffolds for you — it's <Form> with the local renderer registry + chrome styles baked in, so there's no registry prop to pass.

What's included

  • 12 built-in controls: text, textarea, number, email, dropdown, multiselect, checkbox, checkboxList, radioGroup, date, file, custom
  • 3 categorical dependency kinds: propsDependsOn (dynamic question/group props — hidden / required / readOnly / options / minDate / placeholder / …), valueDependsOn (derive a value), resetDependsOn (reset on rising edge)
  • Group-level dependencies with CSS-hide that preserves descendant field state across show/hide
  • Multi-step wizard with per-step validation, step-level visibility, and resumable state via localStorage
  • Plugin lifecycle (onInit / onChange / onSubmit / onDestroy) with built-in loggerPlugin and autosavePlugin
  • Renderer registry — every control's UI is swappable; default renderers come from the @easy-forms shadcn registry (own-the-code; copied into your repo)
  • Custom external store + useSyncExternalStore — only the changed field's subscribers re-render
  • Async validators with race protection — stale results are dropped if the value changed before they resolved
  • Cycle detection in dev — fieldfield, fieldgroup, groupgroup dep loops fail loudly

Question schema

interface FormSchema<TFormData> {
  title?: string;
  description?: string;
  groups: Group<TFormData>[];
  wizard?: WizardConfig<TFormData>;
}

interface Group<TFormData> {
  title?: string;
  layout?: 'stack' | 'grid';
  gridCols?: 1 | 2 | 3 | 4 | 6 | 12;
  dependents?: Dependency<TFormData>;   // group-level visibility
  questions?: Question<TFormData>[];
  groups?: Group<TFormData>[];          // recursive — arbitrary depth
}

Each Question is a discriminated union by control — narrowing happens automatically:

{
  key: 'age',
  label: 'Age',
  control: 'number',
  validators: { required: true, min: 13, max: 130 },  // typed for numbers
  prefix: '

Putting validators.minLength: 3 on a number control is a TypeScript error.

Validators

validators: {
  required: true,                       // or { value: true, message: 'Email is required' }
  minLength: 3,                         // strings & arrays
  maxLength: 200,
  min: 0,                               // numbers
  max: 100,
  pattern: /^[A-Z][a-z]+$/,
  email: true,
  custom: async (value, allValues) =>
    (await usernameTaken(value)) ? 'Already taken' : null,
}

custom can be a single function or a Record<string, fn> of named validators. Sync errors short-circuit async customs.

Dependencies

Two categories, three built-in kinds:

propsDependsOn — Dynamic question/group props. One generic dep replaces what would otherwise need separate visibilityDependsOn / requiredDependsOn / optionsDependsOn / minDateDependsOn / readOnlyDependsOn handlers. The compute returns a Partial<RuntimeProps> and the engine merges it into the target's runtime override map. Adding a new dynamic prop (e.g. dynamic placeholder) costs zero new code.

// Cascading dropdown — set options + placeholder + disabled in ONE rule
{ key: 'state', control: 'dropdown', options: [],
  dependents: {
    propsDependsOn: [
      { fieldNames: ['country'], compute: (v) => ({
          options: STATES_BY_COUNTRY[v.country as string] ?? [],
          disabled: !v.country,
          placeholder: v.country ? 'Pick a state' : 'Pick a country first',
      }) },
    ],
  },
}

// Multiple independent rules — last write wins for the same prop
{ key: 'email', control: 'email', clearWhenHidden: true,
  dependents: {
    propsDependsOn: [
      { fieldNames: ['subscribe'], compute: (v) => ({
          hidden: v.subscribe !== true,
          required: v.subscribe === true,
      }) },
    ],
  },
}

// Group-level (hides the whole section; child field state preserved via CSS-hide)
{ id: 'marketing', title: 'Marketing preferences',
  dependents: {
    propsDependsOn: [
      { fieldNames: ['subscribe'], compute: (v) => ({ hidden: v.subscribe !== true }) },
    ],
  },
  questions: [...],
}

valueDependsOn — Derive a value from other fields. Deferred via queueMicrotask; does NOT flip the form's isDirty.

{ key: 'total', control: 'number',
  dependents: {
    valueDependsOn: {
      fieldNames: ['price', 'qty'],
      compute: (v) => Number(v.price) * Number(v.qty),
    }
  }
}

resetDependsOn — Reset a field to its initialValue when a predicate flips false → true.

{ key: 'shippingAddress', control: 'text',
  dependents: {
    resetDependsOn: {
      fieldNames: ['useBilling'],
      when: (v) => v.useBilling === true,
    },
  },
}
Hide policy

A field's clearWhenHidden: true resets it on the false → true edge of its effective hidden. Same option on a group cascades the reset to every descendant. Default is off — state is preserved across show/hide.

Cycle detection

In dev, the engine builds a dependency graph and runs DFS. Any fieldfield, fieldgroup, or groupgroup cycle throws with a [group] a → b → [group] a path. Production builds skip the check.

Adding a custom dep kind

Drop down to the low-level DependencyHandler { getDependencies, apply } interface:

const myDep: DependencyHandler<MyConfig> = {
  getDependencies: (cfg) => cfg.fieldNames,
  apply(cfg, ctx) {
    if (ctx.target.kind !== 'field') return;
    // ... do something with ctx.store
  },
};

<EasyForm
  schema={schema}
  dependencyHandlers={{ myDep }}
  onSubmit={save}
/>

Wizard

Add wizard.steps to the schema and <Form> switches into wizard mode automatically:

const schema: FormSchema = {
  groups: [],
  wizard: {
    persistKey: 'signup-wizard',     // resumable across refresh
    steps: [
      { id: 'identity',  title: 'Identity',  groups: [...] },
      { id: 'address',   title: 'Address',   groups: [...] },
      { id: 'review',    title: 'Review',    groups: [...] },
    ],
  },
};
  • All fields from every step are registered up-front; cross-step valueDependsOn works.
  • goNext validates only the current step.
  • Step-level propsDependsOn (emitting { hidden: true }) skips hidden steps in navigation.
  • On successful submit, the persistKey is cleared.

Use the useWizard() hook inside the form tree for custom navigation UI.

Plugins

import { loggerPlugin, autosavePlugin, definePlugin } from '@easy-forms/core';
import { EasyForm } from '@/components/easy-forms/easy-form';

const analyticsPlugin = definePlugin({
  name: 'analytics',
  onSubmit: (_ctx, values) => track('form-submitted', values),
});

<EasyForm
  schema={schema}
  plugins={[
    loggerPlugin(),
    autosavePlugin({ key: 'draft' }),
    analyticsPlugin,
  ]}
  onSubmit={save}
/>

Lifecycle hooks: onInit, onChange(key, value), onSubmit(values), onDestroy.

Renderer registry

Every control type maps to a renderer in the registry. Because the default renderers are scaffolded into your repo, the usual way to change one is to edit its file in place (or point a control at your own component in @/components/easy-forms/registry.ts). You can also override at the call site with <Form>:

import { easyFormsRegistry } from '@/components/easy-forms/registry';

<Form registry={{ ...easyFormsRegistry, dropdown: MyFancyDropdown }} ... />

A renderer is a React component with this prop shape:

import type { RendererProps, TextQuestion } from '@easy-forms/core';

function MyTextRenderer(props: RendererProps<TextQuestion>) {
  return (
    <input
      value={props.value ?? ''}
      placeholder={props.question.placeholder}
      readOnly={!!props.question.readOnly}
      onChange={(e) => props.onChange(e.target.value)}
      onBlur={props.onBlur}
      aria-invalid={(props.touched && !!props.error) || undefined}
    />
  );
}

Renderers read everything from props.question — the effective question with any dynamic runtime overrides already merged in (options, required, readOnly, disabled, minDate, placeholder, …). There is no separate computed map and no side required / readOnly props; a propsDependsOn rule that emits { required: true } shows up as props.question.required.

Hooks

Hook Use
useField(key) Subscribe to one field's value/error/dirty — only re-renders for that field
useFormState() Subscribe to form-wide isDirty / isValid / isSubmitting / values / errors
useFormValues() Shortcut for useFormState().values
useWatch(key) / useWatch(keys) Read one or more values without subscribing to the entire form
useWizard() Inside a wizard form — current step, navigation, submit()

Headless usage

If you want full control of layout and rendering, skip <Form> and wire the store yourself:

import { createFormStore, FormStoreProvider, useField } from '@easy-forms/core';

const store = createFormStore({ initialValues: { name: '' } });
store.registerField({ key: 'name', validators: { required: true } });

function App() {
  return (
    <FormStoreProvider store={store}>
      <NameField />
      <button onClick={() => store.submit(async (v) => console.log(v))}>Save</button>
    </FormStoreProvider>
  );
}

function NameField() {
  const { value, error, setValue, setTouched } = useField<string>('name');
  return <input value={value ?? ''} onChange={(e) => setValue(e.target.value)} onBlur={() => setTouched()} />;
}

License

MIT

, }

Putting __INLINE_CODE_26__ on a number control is a TypeScript error.

Validators

__CODE_BLOCK_6__

__INLINE_CODE_27__ can be a single function or a __INLINE_CODE_28__ of named validators. Sync errors short-circuit async customs.

Dependencies

Two categories, three built-in kinds:

__INLINE_CODE_29__ — Dynamic question/group props. One generic dep replaces what would otherwise need separate __INLINE_CODE_30__ / __INLINE_CODE_31__ / __INLINE_CODE_32__ / __INLINE_CODE_33__ / __INLINE_CODE_34__ handlers. The compute returns a __INLINE_CODE_35__ and the engine merges it into the target's runtime override map. Adding a new dynamic prop (e.g. dynamic __INLINE_CODE_36__) costs zero new code.

__CODE_BLOCK_7__

__INLINE_CODE_37__ — Derive a value from other fields. Deferred via __INLINE_CODE_38__; does NOT flip the form's __INLINE_CODE_39__.

__CODE_BLOCK_8__

__INLINE_CODE_40__ — Reset a field to its __INLINE_CODE_41__ when a predicate flips false → true.

__CODE_BLOCK_9__
Hide policy

A field's __INLINE_CODE_42__ resets it on the false → true edge of its effective __INLINE_CODE_43__. Same option on a group cascades the reset to every descendant. Default is off — state is preserved across show/hide.

Cycle detection

In dev, the engine builds a dependency graph and runs DFS. Any fieldfield, fieldgroup, or groupgroup cycle throws with a __INLINE_CODE_44__ path. Production builds skip the check.

Adding a custom dep kind

Drop down to the low-level __INLINE_CODE_45__ interface:

__CODE_BLOCK_10__

Wizard

Add __INLINE_CODE_46__ to the schema and __INLINE_CODE_47__ switches into wizard mode automatically:

__CODE_BLOCK_11__
  • All fields from every step are registered up-front; cross-step __INLINE_CODE_48__ works.
  • __INLINE_CODE_49__ validates only the current step.
  • Step-level __INLINE_CODE_50__ (emitting __INLINE_CODE_51__) skips hidden steps in navigation.
  • On successful submit, the __INLINE_CODE_52__ is cleared.

Use the __INLINE_CODE_53__ hook inside the form tree for custom navigation UI.

Plugins

__CODE_BLOCK_12__

Lifecycle hooks: __INLINE_CODE_54__, __INLINE_CODE_55__, __INLINE_CODE_56__, __INLINE_CODE_57__.

Renderer registry

Every control type maps to a renderer in the registry. Because the default renderers are scaffolded into your repo, the usual way to change one is to edit its file in place (or point a control at your own component in __INLINE_CODE_58__). You can also override at the call site with __INLINE_CODE_59__:

__CODE_BLOCK_13__

A renderer is a React component with this prop shape:

__CODE_BLOCK_14__

Renderers read everything from __INLINE_CODE_60__ — the effective question with any dynamic runtime overrides already merged in (__INLINE_CODE_61__, __INLINE_CODE_62__, __INLINE_CODE_63__, __INLINE_CODE_64__, __INLINE_CODE_65__, __INLINE_CODE_66__, …). There is no separate __INLINE_CODE_67__ map and no side __INLINE_CODE_68__ / __INLINE_CODE_69__ props; a __INLINE_CODE_70__ rule that emits __INLINE_CODE_71__ shows up as __INLINE_CODE_72__.

Hooks

Hook Use
__INLINE_CODE_73__ Subscribe to one field's value/error/dirty — only re-renders for that field
__INLINE_CODE_74__ Subscribe to form-wide __INLINE_CODE_75__
__INLINE_CODE_76__ Shortcut for __INLINE_CODE_77__
__INLINE_CODE_78__ / __INLINE_CODE_79__ Read one or more values without subscribing to the entire form
__INLINE_CODE_80__ Inside a wizard form — current step, navigation, __INLINE_CODE_81__

Headless usage

If you want full control of layout and rendering, skip __INLINE_CODE_82__ and wire the store yourself:

__CODE_BLOCK_15__

License

MIT

Keywords