Patterns
Common Form Intelligence recipes — wizard, autosave, offline submit, plugins.
Previous: Plugins · Next: Migration
Playground
Try Workflow, Submission, HTML constraints, Plugins, and CAPTCHA (loading / pending gates).
Import path
Recipes below use @jayoncode/form-intelligence unless noted. Formatters → /format; lifecycle plugins → /plugins; schema packages → @jayoncode/form-intelligence-zod (etc.). Entrypoints.
Multi-step wizard
const form = createForm({
initialValues: { email: "", plan: "", card: "" },
validators: {
email: [required, email],
plan: [required],
card: [required],
},
workflow: {
wizard: {
initialStep: 0,
steps: [
{ id: "account", fields: ["email"] },
{ id: "plan", fields: ["plan"] },
{ id: "payment", fields: ["card"] },
],
},
},
onSubmit: async (values) => api.checkout(values),
});
await form.workflow.next(); // validates current step fields
form.workflow.prev();
await form.workflow.goTo(2);
form.state.workflow.currentStep;
form.state.workflow.progress; // 0–100Autosave + draft restore
createForm({
initialValues: { body: "" },
workflow: {
autosave: {
enabled: true,
debounceMs: 400,
onSave: (values) => api.patchDraft(values),
},
draft: {
enabled: true,
storageKey: "editor:draft",
},
},
});Status: form.state.workflow.isAutosaving, form.state.workflow.lastAutosaveAt.
Manual save: form.saveDraft().
Composition: draft on tab hide (Browser Lifecycle)
Composition without coupling — install both packages; wire via the Form Intelligence plugin (optional peer). No shared runtime.
import { createForm } from "@jayoncode/form-intelligence";
import { createBrowserLifecyclePlugin } from "@jayoncode/form-intelligence/plugins";
import { createBrowserLifecycle } from "@jayoncode/browser-lifecycle";
const lifecycle = createBrowserLifecycle({ autoStart: true, idleTimeout: 60_000 });
const form = createForm({
initialValues: { notes: "" },
workflow: {
draft: { enabled: true, storageKey: "editor-draft" },
// optional: also flush queued submits when back online
offlineQueue: { enabled: true, storageKey: "editor-offline" },
},
plugins: [
createBrowserLifecyclePlugin({
saveDraftOnHidden: true, // page:hidden → form.saveDraft()
flushOfflineQueueOnOnline: true,
lifecycle, // shared session; plugin does not dispose it
}),
],
onSubmit: async (values) => api.publish(values),
});
// App owns the session:
lifecycle.dispose();
form.destroy();| Signal | Effect |
|---|---|
page:hidden | Persist draft so a refresh / resume does not lose work |
connection:online | Flush offline submit queue when configured |
Manual alternative (no plugin): lifecycle.on("page:hidden", () => form.saveDraft()).
API tables: Integrations → Browser lifecycle · Browser events: Visibility.
Composition: dirty audit / patch (Object Diff)
Composition without coupling — optional peer @jayoncode/object-diff for submit-time audit or instance diffs.
import { createForm } from "@jayoncode/form-intelligence";
import { createObjectDiffPlugin } from "@jayoncode/form-intelligence/plugins";
import { diff, hasChanges, patch, applyPatch } from "@jayoncode/object-diff";
const form = createForm({
initialValues: { title: "", body: "" },
plugins: [
createObjectDiffPlugin({
onSubmitDiff: async (diffResult, values) => {
// e.g. send audit trail / only PATCH changed fields
if (diffResult.hasChanges) {
await api.audit(diffResult);
}
},
diffOptions: { maxDepth: 8, treatUndefinedAsMissing: true },
}),
],
onSubmit: async (values) => api.save(values),
});
// Ad-hoc compare (no plugin required):
await form.diffFromDefaults({ maxDepth: 8 });
await form.diffFrom(savedSnapshot);
await form.submit({ includeDiff: true });
// Pure Object Diff (no Form Intelligence):
if (hasChanges(saved, draft)) {
const ops = patch(diff(saved, draft));
const next = applyPatch(saved, ops);
}| Goal | Prefer |
|---|---|
| Audit on successful submit | createObjectDiffPlugin |
| Compare live values to defaults / snapshot | form.diffFromDefaults() / form.diffFrom() |
| Generate / apply JSON Patch outside forms | diff / patch / applyPatch from @jayoncode/object-diff |
Details: Integrations → Object Diff · State → Object diffs · Object Diff integrations.
Composition: idle soft-save (Browser Lifecycle)
Composition without coupling — app code owns the wire; no shared runtime.
When the session goes idle, persist a draft so a laptop lid-close / long pause does not lose work. Pair with autosave for while-active editing.
import { createForm } from "@jayoncode/form-intelligence";
import { createBrowserLifecycle } from "@jayoncode/browser-lifecycle";
const lifecycle = createBrowserLifecycle({ autoStart: true, idleTimeout: 90_000 });
const form = createForm({
initialValues: { body: "" },
workflow: {
autosave: {
enabled: true,
debounceMs: 400,
onSave: (values) => api.patchDraft(values),
},
draft: { enabled: true, storageKey: "editor:draft" },
},
onSubmit: async (values) => api.publish(values),
});
const stopIdle = lifecycle.on("session:idle", () => {
form.saveDraft();
// Optional UX: show “Away — draft saved” from snapshot.activity
});
const stopActive = lifecycle.on("activity:detected", () => {
// Optional UX: clear the away banner
});
lifecycle.dispose();
stopIdle();
stopActive();
form.destroy();| Signal | Typical app reaction |
|---|---|
session:idle | form.saveDraft() + banner |
activity:detected | Resume editing UX |
Idle concepts: Browser Lifecycle → Idle. Draft API: Patterns → Autosave.
Composition: hide + dirty check (Browser Lifecycle + Object Diff)
Three packages, still no coupling — install what you need; wire in app code (or use the FI plugins for hide/diff separately).
import { createForm } from "@jayoncode/form-intelligence";
import { createBrowserLifecycle } from "@jayoncode/browser-lifecycle";
import { hasChanges } from "@jayoncode/object-diff";
const lifecycle = createBrowserLifecycle({ autoStart: true });
const defaults = { title: "", body: "" };
const form = createForm({
initialValues: defaults,
workflow: { draft: { enabled: true, storageKey: "article:draft" } },
});
const stop = lifecycle.on("page:hidden", async () => {
const live = form.getValues();
if (!hasChanges(defaults, live)) {
return; // nothing to persist
}
form.saveDraft();
// Optional: await form.diffFromDefaults() for an audit payload
});
lifecycle.dispose();
stop();
form.destroy();Prefer the FI plugins when you want the same behavior with less glue (createBrowserLifecyclePlugin + createObjectDiffPlugin). Prefer this manual pattern when you need a custom dirty predicate.
Offline submit queue
createForm({
initialValues: { email: "" },
workflow: {
offlineQueue: {
enabled: true,
storageKey: "signup:offline",
},
},
onSubmit: async (values) => api.register(values),
});
// When back online:
await form.flushOfflineQueue();Pair with createBrowserLifecyclePlugin({ flushOfflineQueueOnOnline: true }).
Plugin hooks
createForm({
initialValues: { email: "" },
plugins: [
{
name: "audit",
setup(_form, api) {
api.on("beforeSubmit", () => {
if (!window.confirm("Submit?")) return false;
});
api.on("afterValidate", ({ valid }) => {
console.log("valid?", valid);
});
return {
onDestroy() {
console.log("cleanup");
},
};
},
},
],
});
// Or later: form.use({ name: "audit", setup(...) { ... } });Hooks: beforeValidate, afterValidate, beforeSubmit, afterSubmit, onAutosave, onDraftRestore. Full authoring rules (ownership, engines, testing): Plugins — author guide.
Schema adapters (Zod)
import { zodAdapter } from "@jayoncode/form-intelligence-zod";
import { z } from "zod";
createForm({
initialValues: { email: "" },
schema: zodAdapter(z.object({ email: z.string().email() })),
onSubmit,
});Core stays free of Zod — adapters implement SchemaAdapter.
Headless HTML progressive enhancement
createForm({
target: "#register",
// schema optional — HTML required / type="email" / minlength also import as validators
schema: { email: "email", password: "password" },
onSubmit: async (values) => api.register(values),
});<form id="register">
<input name="email" required type="email" />
<input name="password" required minlength="8" type="password" />
<button type="submit">Register</button>
</form>Phase 1 attributes become FI validators on attach (Field > Schema > HTML). Errors announce with role="alert" and aria-invalid automatically (novalidate). Lab: HTML constraints.
Done? Browse the API Reference or open the playground.
