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
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.

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