S

Plugins

Extend forms with lifecycle hooks, custom fields, validators, and config/value transformers.

Plugins

The plugin system lets you extend @saastro/forms without modifying its source. Plugins can hook into the form lifecycle, register custom field types, add validators, and transform configs or values before submission.


Using Plugins

Create a PluginManager, register plugins, and pass it to your form:

import { FormBuilder, PluginManager, localStoragePlugin, analyticsPlugin } from '@saastro/forms';

const pm = new PluginManager();
pm.register(localStoragePlugin);
pm.register(analyticsPlugin);

const config = FormBuilder.create('my-form')
  .usePlugins(pm)
  .addField('email', (f) => f.type('email').label('Email').required().email())
  .addStep('main', ['email'])
  .build();

Or pass the plugin manager directly to the Form component:

<Form config={config} pluginManager={pm} />

Lifecycle Hooks

Plugins can implement any combination of 6 lifecycle hooks:

HookWhen It FiresArguments
onFormInitForm component mounts(config: FormConfig)
onFieldChangeAny field value changes(fieldName, value, allValues)
onStepChangeStep navigation occurs(stepId, values)
onBeforeSubmitBefore submission (can be async)(values)
onAfterSubmitAfter successful submission(values, response)
onErrorWhen an error occurs(error, values?)

Hooks from all registered plugins execute in registration order. If onBeforeSubmit throws, the submission is cancelled.

import { definePlugin } from '@saastro/forms';

const loggingPlugin = definePlugin({
  name: 'logging',
  version: '1.0.0',
  onFormInit(config) {
    console.log(`Form "${config.formId}" initialized`);
  },
  onFieldChange(fieldName, value) {
    console.log(`Field "${fieldName}" changed to:`, value);
  },
  onBeforeSubmit(values) {
    console.log('About to submit:', values);
  },
  onAfterSubmit(values, response) {
    console.log('Submitted successfully:', response);
  },
  onError(error) {
    console.error('Form error:', error.message);
  },
});

Config & Value Transformers

transformConfig

Runs before the form schema and defaults are built. Use it to inject fields, modify schemas, or add submit actions.

const prefixPlugin = definePlugin({
  name: 'prefix',
  version: '1.0.0',
  transformConfig(config) {
    // Add a hidden field to every form
    return {
      ...config,
      fields: {
        ...config.fields,
        _source: {
          type: 'html' as const,
          label: '',
          content: '',
          schema: { required: false },
        },
      },
    };
  },
});

transformValues

Runs before submit actions execute. Use it to add computed values, clean data, or merge extra fields.

const timestampPlugin = definePlugin({
  name: 'timestamp',
  version: '1.0.0',
  transformValues(values) {
    return {
      ...values,
      submittedAt: new Date().toISOString(),
      userAgent: navigator.userAgent,
    };
  },
});

Transformers from multiple plugins are chained in registration order. Each plugin receives the output of the previous one.


Custom Field Types

Plugins can register new field types with custom React renderers:

import { definePlugin } from '@saastro/forms';

const SignatureField = ({ name, fieldConfig, control, colSpanItem }) => (
  <div className={colSpanItem}>
    <canvas id={`sig-${name}`} style={{ border: '1px solid #ccc' }} />
  </div>
);

const signaturePlugin = definePlugin({
  name: 'signature',
  version: '1.0.0',
  registerFields() {
    return {
      signature: SignatureField,
    };
  },
});

Once registered, use the custom type like any built-in type:

.addField('sig', (f) => f.type('signature').label('Your Signature').required())

Custom fields render via the default case in the field renderer. They receive name, fieldConfig, control (from React Hook Form), and colSpanItem (CSS class for grid positioning).


Custom Validators

Register named validators that can be referenced by any field via .customValidators():

const validationPlugin = definePlugin({
  name: 'custom-validators',
  version: '1.0.0',
  validators: {
    uniqueEmail: async (value, context) => {
      const res = await fetch(`/api/check-email?email=${value}`);
      const { exists } = await res.json();
      return exists ? 'Email already registered' : true;
    },
    corporateOnly: (value, context) => {
      const email = String(value);
      const freeProviders = ['gmail.com', 'yahoo.com', 'hotmail.com'];
      const domain = email.split('@')[1];
      return freeProviders.includes(domain) ? 'Please use your corporate email' : true;
    },
  },
});

Reference validators on fields:

.addField('email', (f) =>
  f.type('email')
    .label('Work Email')
    .required()
    .email()
    .customValidators('uniqueEmail', 'corporateOnly')
)

Validators are chained as Zod superRefine checks. They receive the field value and a ValidationContext with fieldName and allValues. Return true for valid or a string error message for invalid.


Built-in Plugins

Instances vs Factories: Two of the built-in plugins are ready-to-use instances — register them directly. The other two are factory functions that need configuration — call them with parentheses to get a plugin instance.

// Instances — register directly (no parentheses)
pm.register(localStoragePlugin);
pm.register(analyticsPlugin);

// Factories — call with config (parentheses required)
pm.register(autosavePlugin({ interval: 30000 }));
pm.register(databowlPlugin({ token: '...', fieldMapping: { ... } }));
pm.register(recaptchaPlugin({ siteKey: '...' }));

localStoragePlugin

Persists form progress to localStorage. Clears saved data after successful submission.

import { localStoragePlugin } from '@saastro/forms';

pm.register(localStoragePlugin);

Uses hooks: onFormInit (load saved values), onFieldChange (save on change), onAfterSubmit (clear storage).

analyticsPlugin

Sends Google Analytics events via gtag() for form interactions.

import { analyticsPlugin } from '@saastro/forms';

pm.register(analyticsPlugin);

Tracked events:

  • form_init — form initialized (with form_id)
  • form_step — step change (with step_id)
  • form_submit — successful submission
  • form_error — error occurred (with error_message)

autosavePlugin

Periodically saves form data to an endpoint via POST request.

import { autosavePlugin } from '@saastro/forms';

pm.register(
  autosavePlugin({
    interval: 30000, // Debounce interval in ms (default: 60000)
    endpoint: '/api/autosave', // POST endpoint (default: '/api/autosave')
  }),
);

Debounces on field change — sends a POST with the current form values as JSON after the interval.

databowlPlugin

DataBowl lead integration. Automatically injects an HTTP submit action via transformConfig and merges static fields via transformValues.

import { databowlPlugin } from '@saastro/forms';

pm.register(
  databowlPlugin({
    token: import.meta.env.DATABOWL_TOKEN,
    endpoint: '/api/send-lead', // Optional proxy (default: DataBowl API)
    fieldMapping: {
      nombre: 'first_name',
      email: 'email_address',
      telefono: 'phone',
    },
    staticFields: {
      source: 'landing-energy',
      campaign: 'google-ads',
    },
    bodyFormat: 'json', // 'json' | 'url-encoded' (default)
    continueOnError: false, // Stop form submission on error (default)
  }),
);

Or use the FormBuilder shorthand:

FormBuilder.create('lead')
  .useDatabowl({ token: '...', fieldMapping: { ... } })
  .build();

recaptchaPlugin

Google reCAPTCHA v3 integration. Injects the script on form init and automatically adds a fresh token to every submission.

import { recaptchaPlugin } from '@saastro/forms';

pm.register(
  recaptchaPlugin({
    siteKey: 'your-recaptcha-site-key',
    action: 'submit', // Action name sent to Google (default: 'submit')
    tokenField: '_recaptchaToken', // Field name in submitted values (default: '_recaptchaToken')
  }),
);

What it does:

  • onFormInit — Injects the reCAPTCHA v3 script into <body> (deduped, skips if already loaded)
  • transformValues — Calls grecaptcha.execute() to get a fresh token and adds it to the form values
  • cleanup — Removes the injected script on unmount

Your backend then verifies the token:

// Server-side verification
const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
  method: 'POST',
  body: new URLSearchParams({
    secret: process.env.RECAPTCHA_SECRET_KEY,
    response: values._recaptchaToken,
  }),
});

This plugin replaces the deprecated useRecaptcha hook. The hook only injected the script — this plugin also handles token generation automatically.


Creating Your Own Plugin

Use definePlugin() for type safety:

import { definePlugin } from '@saastro/forms';

export const myPlugin = definePlugin({
  name: 'my-plugin',
  version: '1.0.0',
  description: 'Does something useful',

  // Optional: initialize with options
  options: { apiKey: '...' },
  init(options) {
    console.log('Plugin initialized with:', options);
  },

  // Lifecycle hooks (all optional)
  onFormInit(config) {
    /* ... */
  },
  onFieldChange(fieldName, value, allValues) {
    /* ... */
  },
  onStepChange(stepId, values) {
    /* ... */
  },
  onBeforeSubmit(values) {
    /* ... */
  },
  onAfterSubmit(values, response) {
    /* ... */
  },
  onError(error, values) {
    /* ... */
  },

  // Transformers (all optional)
  transformConfig(config) {
    return config;
  },
  transformValues(values) {
    return values;
  },

  // Custom fields (optional)
  registerFields() {
    return { myField: MyFieldComponent };
  },

  // Custom validators (optional)
  validators: {
    myRule: (value, ctx) => (value ? true : 'Required'),
  },

  // Cleanup (optional)
  cleanup() {
    console.log('Plugin cleaned up');
  },
});

API Reference

PluginManager

import { PluginManager, globalPluginManager } from '@saastro/forms';
MethodDescription
register(plugin)Register a plugin (throws if name already taken)
unregister(name)Remove a plugin (calls cleanup)
getPlugin(name)Get a registered plugin
getAllPlugins()Get all registered plugins
getCustomField(type)Get a custom field renderer
hasCustomField(type)Check if a custom field type exists
getValidator(name)Get a custom validator
executeHook(hook, ...args)Execute a lifecycle hook on all plugins
transformConfig(config)Run all config transformers
transformValues(values)Run all value transformers
cleanup()Clean up all plugins and clear registries
getStats()Get plugin system statistics

FormPlugin Interface

interface FormPlugin {
  name: string;
  version: string;
  description?: string;
  options?: Record<string, unknown>;
  init?: (options?) => void;
  cleanup?: () => void;

  // Lifecycle hooks
  onFormInit?: (config: FormConfig) => void;
  onFieldChange?: (fieldName: string, value: unknown, allValues: Record<string, unknown>) => void;
  onStepChange?: (stepId: string, values: Record<string, unknown>) => void;
  onBeforeSubmit?: (values: Record<string, unknown>) => void | Promise<void>;
  onAfterSubmit?: (values: Record<string, unknown>, response: unknown) => void;
  onError?: (error: Error, values?: Record<string, unknown>) => void;

  // Transformers
  transformConfig?: (config: FormConfig) => FormConfig;
  transformValues?: (values: Record<string, unknown>) => Record<string, unknown>;

  // Extensions
  registerFields?: () => Record<string, FieldRenderer>;
  validators?: Record<
    string,
    (value: unknown, context: ValidationContext) => boolean | string | Promise<boolean | string>
  >;
}

ValidationContext

interface ValidationContext {
  fieldName: string;
  allValues: Record<string, unknown>;
  abortSignal?: AbortSignal;
}

definePlugin()

import { definePlugin } from '@saastro/forms';

const plugin = definePlugin({ name: '...', version: '...' /* ... */ });

A simple identity function that provides type checking for plugin objects.

globalPluginManager

A shared PluginManager instance exported for convenience. You can use it or create your own instances.

import { globalPluginManager } from '@saastro/forms';

globalPluginManager.register(myPlugin);