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:
| Hook | When It Fires | Arguments |
|---|---|---|
onFormInit | Form component mounts | (config: FormConfig) |
onFieldChange | Any field value changes | (fieldName, value, allValues) |
onStepChange | Step navigation occurs | (stepId, values) |
onBeforeSubmit | Before submission (can be async) | (values) |
onAfterSubmit | After successful submission | (values, response) |
onError | When 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 (withform_id)form_step— step change (withstep_id)form_submit— successful submissionform_error— error occurred (witherror_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— Callsgrecaptcha.execute()to get a fresh token and adds it to the form valuescleanup— 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
useRecaptchahook. 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';
| Method | Description |
|---|---|
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);