Skip to content

Plugins ​

Add cross-cutting behavior — analytics, guards, or integrations — without forking core.

Previous: Adapters · Next: Patterns

Playground

Plugins explorer → — register hooks and inspect the event log.

Import path ​

NeedImport
FormPlugin type, form.use(), createForm({ plugins })@jayoncode/form-intelligence
Browser lifecycle / keyboard factories@jayoncode/form-intelligence/plugins
Middleware stage maps@jayoncode/form-intelligence/middleware
UI policies (ui())@jayoncode/form-intelligence/ui
CAPTCHA (captcha(), turnstile())@jayoncode/form-intelligence/captcha
Upload transport (uploadTransport())@jayoncode/form-intelligence/upload
DevTools inspector@jayoncode/form-intelligence/devtools

Full map: Entrypoints.

Problem → solution ​

ProblemSolution
Cross-cutting behavior scattered in app codeform.use(plugin) or createForm({ plugins })
Need lifecycle + keyboard without forking core/plugins factories
Publishing a reusable extensionFollow the author guide

Overview ​

Plugins receive the form instance and a typed hook API. Return a cleanup function or { onDestroy } for teardown.

Register at create time (convenient, same as sequential form.use):

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

const form = createForm({
  initialValues: { notes: "" },
  plugins: [createBrowserLifecyclePlugin({ saveDraftOnHidden: true })],
});

Or after construction:

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

const audit: FormPlugin = {
  name: "audit",
  order: 10,
  setup(form, api) {
    api.on("beforeSubmit", () => {
      console.log("submitting", form.values());
    });
    return {
      onDestroy() {
        console.log("audit removed");
      },
    };
  },
};

form.use(audit);
// or form.registerPlugin(audit);

plugins: [a, b] is equivalent to form.use(a); form.use(b); in array order (after core setup, before the initial draft-restore hook when a draft was restored).


Plugin author guide ​

Use this section when writing a reusable plugin (app-local or published). Apps that only need a one-off hook can stay with the overview examples above.

Choose the right extension point ​

NeedPreferAvoid
Observe / cancel validate or submitPlugin hooks (api.on)Forking createForm
Onion pipeline around setValue / submitErrorMiddleware (form.useMiddleware)Duplicating hook names for the same work
Show / hide / require / disable fieldswhen() rules → PresentationWriting fieldUi from a plugin
Button UX policies (errorDisplay, disableSubmitWhen)ui() from /uiReimplementing showError / canSubmit
Hard block submit startLet submissionGuard() / rules disableSubmit own it; optionally cancel in beforeSubmitInventing a second hard-guard API
Inspect state in dev/devtoolsShipping DevTools in production by default

Rules and Presentation remain the owners of UI intent (visible, disabled, required, formUi.submitDisabled). /ui only projects, composes, or explains. Plugins should read those APIs, not invent parallel meanings.

See also: UI projection, Submission — hard guards vs button UX, Rules.

Shape checklist ​

FieldRequiredConvention
nameYesStable kebab or camel id (audit, ui, devtools). Used in listPlugins() and error reports.
setup(form, api)YesRegister hooks / listeners; return cleanup or { onDestroy }.
orderNoLower runs earlier among plugins with the same hook (default registration order). Prefer explicit gaps (10, 20, …).
versionNoSemver of your plugin package (metadata for DevTools).
enginesNoSemver range against @jayoncode/form-intelligence (>=3.4.0, ^3.4.0). Checked at register — throws ConfigurationError if incompatible.
ts
import type { FormPlugin } from "@jayoncode/form-intelligence";

export function createAuditPlugin(options?: { readonly log?: typeof console.log }): FormPlugin {
  const log = options?.log ?? console.log;
  return {
    name: "audit",
    version: "1.0.0",
    engines: ">=3.4.0",
    order: 50,
    setup(form, api) {
      const off = api.on("afterSubmit", ({ success, values }) => {
        log("afterSubmit", { success, values });
      });
      return () => {
        off();
      };
    },
  };
}

What plugins may do ​

  • Subscribe to lifecycle hooks and form events (change, validated, …).
  • Call public form APIs: validate(), submit(), setValue(), saveDraft(), listPlugins(), form.ui.*, submissionGuard(), getPresentation().
  • Register policies via the documented ui() plugin pattern (or your own policy store that does not redefine Presentation keys).
  • Cancel guard phases by returning false from beforeValidate / beforeSubmit (same stack as middleware).

What plugins must not do ​

Don'tWhy
Mutate form.state.fieldUi / invent required / visiblePresentation + rules own UI intent; use when().require() / schema baseline instead
Treat form.ui.canSubmit === false as a hard engine blockSoft UX only — see Submission
Throw unchecked inside setup without expecting isolationsetup throw → plugin not registered; report via onPluginError
Rely on private core modulesPublic surface = main entry + documented subpaths
Enable DevTools by default in library consumers' production buildsOpt-in only (Integrations → DevTools)

Hooks vs middleware ​

form.useMiddleware and plugin hooks share one interceptor pipeline (MIDDLEWARE_HOOK_MAP / PLUGIN_PIPELINE_STAGES):

Stage / hookRole
beforeValidateGuard validation
validateCore validation (documented stage)
afterValidateObserve validation
beforeSubmitGuard submit
submitTransport (documented stage)
afterSubmitObserve submit
submitErrorMiddleware-only error phase

Middleware-only phases: submitError, beforeSetValue, afterSetValue.

Per phase, onion middleware runs first (lower order = outer), then plugin hooks. ctx.halt() (or skipping next() on guard phases) cancels like returning false from a hook.

Prefer hooks for simple observe/cancel. Prefer middleware when you need next(), setValue phases, or submitError.

ts
import {
  MIDDLEWARE_HOOK_MAP,
  PLUGIN_PIPELINE_STAGES,
} from "@jayoncode/form-intelligence/middleware";

form.useMiddleware({
  name: "audit",
  order: 0,
  phases: ["beforeSubmit"],
  run: async (ctx, next) => {
    console.log("submit", ctx.form.values());
    await next();
  },
});

form.useMiddleware builds and runs the onion chain for you via composeMiddleware / runMiddlewareChain, exported from /middleware (also re-exported on /plugins):

ExportRole
composeMiddlewareCompose registered middleware into one onion-ordered runner
runMiddlewareChainRun the composed chain for one phase against a context
MiddlewarePipelineClass that owns registration + per-phase composition (used by form.useMiddleware)

Prefer form.useMiddleware(...) — reach for these directly only when building a custom middleware host (e.g. testing a middleware in isolation without a form).

Error isolation ​

Plugin and middleware failures are isolated so one bad extension cannot brick the form:

FailureBehavior
setup throwReported via onPluginError; plugin is not registered
Guard hook / middleware throw (before*)Cancels the phase; form stays usable
Observer hook throw (after*, autosave, draft)Reported; remaining handlers still run
Incompatible enginesThrows ConfigurationError at register time
ts
const form = createForm({
  initialValues: { email: "" },
  onPluginError: ({ plugin, hook, phase, error }) => {
    console.warn(plugin, hook ?? phase, error);
  },
});

Reference shapes in core ​

FactorySubpathPattern
ui(options?)/uiRegisters policies; cleanup clears them. Does not own Presentation keys.
createDevToolsPlugin() / enableFormDevTools()/devtoolsSide-channel inspector; keep out of default product bundles.
createBrowserLifecyclePlugin / createKeyboardPlugin/pluginsThin adapters over sibling JOC packages.

Study these before inventing new “platform” plugins.

Modules vs plugins ​

Internally, createForm runs one ordered pipeline of FormModules (draft restore, workflow, analytics, …). pluginAsModule(plugin) wraps a FormPlugin so it can run in that same pipeline — this is how plugins: [] / form.use() are implemented, not a separate extension point you need to reach for.

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

const module = pluginAsModule(myPlugin); // FormModule — same shape as built-in modules

FormModuleHost is the internal host createForm uses to start/stop modules in order. Plugin authors should use form.use(plugin) / createForm({ plugins }) — reach for pluginAsModule / FormModuleHost only if you are building an alternate module pipeline (e.g. a test harness) that needs the exact internal contract.

Testing ​

ts
import { createForm, required } from "@jayoncode/form-intelligence";
import { describe, expect, it, vi } from "vitest";
import { createAuditPlugin } from "./audit-plugin.js";

describe("createAuditPlugin", () => {
  it("observes successful submit", async () => {
    const log = vi.fn();
    const onSubmit = vi.fn();
    const form = createForm({
      initialValues: { email: "a@b.com" },
      validators: { email: [required] },
      plugins: [createAuditPlugin({ log })],
      onSubmit,
    });

    await expect(form.submit()).resolves.toBe(true);
    expect(log).toHaveBeenCalled();
    form.destroy();
  });

  it("cancels when beforeSubmit returns false", async () => {
    const onSubmit = vi.fn();
    const form = createForm({
      initialValues: { email: "a@b.com" },
      onSubmit,
      plugins: [
        {
          name: "block",
          setup(_form, api) {
            api.on("beforeSubmit", () => false);
          },
        },
      ],
    });

    await expect(form.submit()).resolves.toBe(false);
    expect(onSubmit).not.toHaveBeenCalled();
    form.destroy();
  });
});

Assert public behavior (submit result, listPlugins(), form.ui.canSubmit) — not private registry internals.

Shipping ​

  1. Depend on @jayoncode/form-intelligence as a peer (match engines).
  2. Export a factory (createXPlugin) that returns FormPlugin — avoid side effects on import.
  3. Document which hooks you use and whether you cancel guard phases.
  4. Keep optional weight behind subpath imports if the plugin is heavy (mirror /devtools).

Maintainer conventions: engineering/019-plugin-author-conventions.


Lifecycle hooks ​

HookWhenReturn false to
beforeValidateBefore validationCancel validation
afterValidateAfter validation—
beforeSubmitAfter valid, before transportCancel submit
afterSubmitAfter submit attempt—
onAutosaveAutosave succeeded—
onDraftRestoreDraft merged on create—
ts
api.on("beforeSubmit", () => false); // block submit
api.on("afterValidate", ({ valid, paths }) => {
  console.log(paths, valid);
});

Form events (lower level) ​

EventFires when
changeA value updates
blur / focusField binding events
validate / validatedValidation runs / finishes
submitSubmit starts
autosaveAutosave triggers
draftDraft persisted
resetForm resets
ts
setup(form) {
  return form.on("change", () => console.log("changed"));
}

Built-in plugin factories ​

ts
import {
  createBrowserLifecyclePlugin,
  createDevToolsPlugin,
  createKeyboardPlugin,
  keyboard,
} from "@jayoncode/form-intelligence/plugins";
import { ui } from "@jayoncode/form-intelligence/ui";

const form = createForm({
  initialValues: { notes: "" },
  plugins: [
    ui({ errorDisplay: "touched" }),
    createBrowserLifecyclePlugin({ saveDraftOnHidden: true }),
    createDevToolsPlugin(),
    createKeyboardPlugin([keyboard.shortcut("mod+s", (f) => f.saveDraft())]),
  ],
});

Aliases: browserLifecyclePlugin, devtoolsPlugin.


Cleanup ​

ts
setup(form, api) {
  const off = api.on("afterSubmit", handler);
  return () => {
    off();
  };
}

Destroying the form runs all plugin cleanups.

Next: Patterns — wizard, offline submit, plugin recipes.

An ecosystem of independent, headless TypeScript libraries engineered for modern web development. Every package includes interactive playgrounds and documentation that evolves alongside the code.