Skip to content

Package overview

Headless form workflow engine — validation, submission, state, and multi-step orchestration without UI coupling.

The problem

Real products do not ship “email + password” forever. They ship checkouts, applications, and onboarding flows where:

What breaks in DIY formsWhat users feel
Conditional fields via useEffect / change glueWrong fields shown; submit still enabled
Autosave + draft restore hand-rolledRefresh kills a 10-minute form
isSubmitting / double-click racesDuplicate charges or blank error states
Wizard step validation scatteredUsers skip steps or get stuck

Form Intelligence makes those workflows declarative on one createForm() instance — keep your markup or bind into any UI.

When to use

  • Multi-field forms with validation, async submit, and submit guards
  • Conditional fields (when()), drafts/autosave, wizards, or offline queue
  • Headless or multi-framework UI (native HTML, React, Vue, …)

When not to use

  • A single uncontrolled input with no validation workflow
  • You only need a UI component library (pick a design system; pair this for orchestration)
  • You need a full CMS / form builder product — this is an engine, not a builder UI

Features

  • Sync + async validation (including multiple async checks per field)
  • HTML constraints on DOM-backed forms (required, minlength, type="email", … → validators on attach)
  • Declarative when() rules (show / require / populate / gate submit)
  • Autosave, draft restore, wizard steps, offline submit queue
  • Headless bind() + optional framework/schema adapters
  • Plugins, middleware, DevTools (opt-in subpaths)

Install

bash
npm install @jayoncode/form-intelligence
bash
pnpm add @jayoncode/form-intelligence
bash
yarn add @jayoncode/form-intelligence

React apps also need the adapter:

bash
npm install @jayoncode/form-intelligence @jayoncode/form-intelligence-react

Best example: SaaS checkout (rules + draft + submit)

Enterprise plan reveals seats and company, drafts survive refresh, submit is async-safe — no effect spaghetti:

ts
import { createForm, when } from "@jayoncode/form-intelligence";

createForm({
  target: "#checkout",
  schema: {
    plan: { required: true },
    seatCount: { required: true },
    companyName: { minLength: 2 },
    email: "email",
  },
  rules: [
    when("plan")
      .equals("enterprise")
      .show("seatCount", "companyName")
      .require("seatCount", "companyName"),
  ],
  workflow: {
    autosave: {
      enabled: true,
      debounceMs: 800,
      onSave: (values) => api.saveDraft(values),
    },
    draft: {
      enabled: true,
      storageKey: "checkout:draft",
    },
  },
  // Same store as form.subscribe() — fires once after create, then on every notify
  subscribe: (form) => {
    syncCheckoutChrome(form.state); // draft badge, plan label, …
  },
  async onSubmit(values) {
    await api.checkout(values);
  },
});
html
<form id="checkout">
  <select name="plan">
    <option value="starter">Starter</option>
    <option value="enterprise">Enterprise</option>
  </select>
  <input name="seatCount" type="number" />
  <input name="companyName" />
  <input name="email" type="email" />
  <button type="submit">Continue</button>
</form>

Try rules in the playground → · Workflow / drafts →

Example: native HTML signup

Enhance existing markup — no React, no manual onChange:

ts
import { createForm } from "@jayoncode/form-intelligence";

createForm({
  target: "#signup",
  schema: { email: "email", name: { required: true, minLength: 2 } },
  validateOn: "onBlur",
  async onSubmit(values) {
    const res = await fetch("/api/signup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    });
    if (!res.ok) throw new Error("Signup failed");
  },
});
html
<form id="signup">
  <input name="email" />
  <input name="name" />
  <button type="submit">Sign up</button>
</form>

Try in the playground →

Example: headless signup with async submit

Programmatic wiring when you own the input layer:

ts
import { createForm, email, required } from "@jayoncode/form-intelligence";

const form = createForm({
  initialValues: { email: "", name: "" },
  validators: {
    email: [required, email],
    name: [required],
  },
  validateOn: "onBlur",
  onSubmit: async (values) => {
    const res = await fetch("/api/signup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    });
    if (!res.ok) throw new Error("Signup failed");
  },
});

// Headless binding — pass to any input implementation
const emailField = form.field("email").bind();

// Imperative read — no subscription needed
console.log(form.state.values, form.isValid());

// Vanilla JS manual render — subscribe only when you own the UI layer
form.subscribe(() => {
  const { values, errors, isSubmitting, isValid } = form.state;
  renderForm({ values, errors, isSubmitting, canSubmit: isValid && !isSubmitting });
});

await form.submit();

You can also pass subscribe on createForm (one listener or an array) — same store, fires once after create, then on every notify, until destroy().

The instance owns values, per-field errors, touched/dirty flags, and submit lifecycle (isSubmitting, duplicate-submit guard). UI layers read state and call bind() handlers — the package does not render components.

Run this flow in the playground →

Example: multiple async checks on one field

Username signup often needs more than one server round-trip. Stack sync rules, then several asyncValidators — they run in order; the first error stops the chain:

ts
import { createForm, required, minLength, asyncValidator } from "@jayoncode/form-intelligence";

createForm({
  initialValues: { username: "" },
  validateOn: "onBlur",
  validators: {
    username: [
      required,
      minLength(3),
      asyncValidator({
        debounce: 300,
        validate: async (value, { signal }) => {
          const res = await fetch(`/api/usernames/reserved?u=${value}`, { signal });
          return (await res.json()).reserved ? "Reserved username." : true;
        },
      }),
      asyncValidator({
        debounce: 400,
        preventDuplicates: true,
        validate: async (value, { signal }) => {
          const res = await fetch(`/api/usernames/available?u=${value}`, { signal });
          return (await res.json()).available ? true : "Already taken.";
        },
      }),
      asyncValidator({
        debounce: 500,
        validate: async (value, { signal }) => {
          const res = await fetch("/api/usernames/moderate", {
            method: "POST",
            body: JSON.stringify({ username: value }),
            signal,
          });
          return (await res.json()).ok ? true : "Choose another username.";
        },
      }),
    ],
  },
});

Full async options → · Playground →

Problem → approach

Typical painForm Intelligence
Validation, touched state, and submit guards duplicated in every form componentOne createForm() instance owns values, errors, dirty/touched, and submit lifecycle
Conditional fields and require rules via useEffectwhen().equals().show().require() — declarative, testable
Draft/autosave races and lost input on refreshBuilt-in autosave debounce + draft storage
onSubmit handlers mix fetch, error mapping, and UI flagsonSubmit is async-first; isSubmitting and duplicate-submit guard are built in
Framework form libs own too much UI or too little workflowHeadless bind() + form.state; subscribe() / createForm({ subscribe }) only for manual UI

Overview

createForm() returns a form instance: a single orchestration boundary for field state, validation timing, submission, and optional workflow (autosave, drafts, wizards).

LayerResponsibility
Field APIPath-based accessors, bind() for headless wiring
ValidationSync/async validators, per-field or form-level timing
SubmissiononSubmit, cancel, retry, offline queue
StateSnapshots, field meta, undo/redo, diffs
Ruleswhen() show/hide/require/populate/disableSubmit
WorkflowAutosave debounce, draft persistence, wizard steps
ExtensionPlugins and adapter interfaces for framework bridges

Form Intelligence complements field-registration libraries (React Hook Form, etc.): it focuses on workflow orchestration, not component libraries.

Entrypoints

Optional engines use tree-shakeable subpaths. Formatters and DevTools are not on the main entry.

See the full map: Entrypoints & subpaths.

GoalPrefer
Create a form@jayoncode/form-intelligence
Mask phone / slug / card@jayoncode/form-intelligence/format
Enable DevTools@jayoncode/form-intelligence/devtools
Browser session plugin@jayoncode/form-intelligence/plugins

Documentation path

Work through the journey groups below. Each guide links to a playground route.

Getting Started

GuideTopicsPlayground
TutorialcreateForm, bind, submitSandbox

Core Concepts

GuideTopicsPlayground
Core conceptsInstance model, flags, architectureState
CapabilitiesForm OS feature map and statusSandbox
EntrypointsMain vs /format, /devtools, /ui, …Sandbox
UI projectionshowError, canSubmit, status, explainUI lab

Guides

GuideTopicsPlayground
ValidationBuilt-in rules, schema, async, HTML constraintsValidation · HTML
SubmissionLoading, cancel, retries, offlineSubmission
StateSnapshots, meta, undo/redo, diffsState
WorkflowAutosave, drafts, wizardWorkflow
Ruleswhen() show/require/populateRules
CalculationsDerived fieldsCalculations
FormattersParse/format pipelinesFormatters

Integrations

GuideTopicsPlayground
IntegrationsSession, keyboard, analyticsIntegrations
AdaptersFramework + schema bridges, DOM-backed HTMLAdapters · HTML

Advanced and Support

GuideTopicsPlayground
PluginsHooks, middleware, author guidePlugins
PerformanceBundle + timing budgetsPerformance
PatternsWizard, offline, recipesSandbox
MigrationDIY autosave + breaking notesWorkflow

Package fit

RequirementApproach
Debounced autosave / draft restoreworkflow.autosave, workflow.draft
Multi-step flows with per-step validationworkflow.wizard
Conditional show / require / populatewhen() rules — Rules
Derived totalsform.calculate()Calculations
Safe async submit with cancel / retrysubmit() + cancelSubmit() + retry
Offline submit queueworkflow.offlineQueue
Framework-agnostic or custom UIfield().bind()
Declarative field lists onlyConsider an adapter or a field-registration library alongside this package

Reference

JOC — headless @jayoncode/* TypeScript libraries. Docs sync from package source via TypeDoc and sync scripts.