Events
Overview
The useForma hook exposes lifecycle events for side effects — analytics tracking, data injection, external state synchronization, and more. Events do not trigger React re-renders and have zero overhead when no listeners are registered.
Available Events
| Event | When it fires |
|---|---|
fieldChanged | After a field value changes via user input, setValues, or resetForm |
preSubmit | At the start of submitForm(), before validation |
postSubmit | After submission completes (success, error, or invalid) |
pageChanged | When the wizard page changes via navigation methods |
formReset | After resetForm() completes and state is reset |
Registration
Declarative (on option)
Pass listeners via the on option on useForma. Callbacks use refs internally — updating the callback does not cause re-subscription or dependency changes.
import { useForma } from "@fogpipe/forma-react";
const forma = useForma({
spec,
onSubmit: handleSubmit,
on: {
fieldChanged: (event) => {
analytics.track("field_changed", {
path: event.path,
source: event.source,
});
},
preSubmit: async (event) => {
event.data.token = await getCSRFToken();
},
postSubmit: (event) => {
if (event.success) router.push("/thank-you");
},
},
});
Imperative (forma.on())
Register listeners dynamically. Returns an unsubscribe function. Multiple listeners per event are supported and fire in registration order.
The on method has a stable reference (via useCallback), so it is safe to use as a useEffect dependency.
const forma = useForma({ spec, onSubmit: handleSubmit });
useEffect(() => {
const unsubscribe = forma.on("fieldChanged", (event) => {
console.log(`${event.path}: ${event.previousValue} → ${event.value}`);
});
return unsubscribe;
}, [forma.on]);
Event Reference
fieldChanged
Fires after a field value is set via setFieldValue, setValues, or resetForm. Does not fire for computed/calculated field changes or on initial mount. Fires in a useEffect after the state update commits — never blocks rendering.
| Property | Type | Description |
|---|---|---|
path | string | Field path (e.g., "age" or "items[0].name") |
value | unknown | New value |
previousValue | unknown | Value before the change |
source | "user" | "reset" | "setValues" | What triggered the change |
Source values:
"user"— fromsetFieldValue(covers both user input via component onChange and direct programmatic calls)"setValues"— fromsetValuesbatch update (fires once per field that changed)"reset"— fromresetForm(fires for each field whose value differs from initial)
on: {
fieldChanged: (event) => {
analytics.track("field_interaction", {
field: event.path,
source: event.source,
});
},
}
preSubmit
Fires at the start of submitForm(), before validation runs. The data object is mutable — consumers can inject or modify fields. Async handlers are awaited before proceeding.
| Property | Type | Description |
|---|---|---|
data | Record<string, unknown> | Mutable — add/modify fields here |
computed | Record<string, unknown> | Read-only snapshot of computed values |
on: {
preSubmit: async (event) => {
// Inject a CSRF token before validation and submission
event.data.csrfToken = await fetchCSRFToken();
// Add a timestamp
event.data.submittedAt = new Date().toISOString();
},
}
postSubmit
Fires after onSubmit resolves or rejects, or after validation failure. At this point isSubmitting is already set back to false.
| Property | Type | Description |
|---|---|---|
data | Record<string, unknown> | The submitted data (reflects preSubmit mutations) |
success | boolean | Whether submission succeeded |
error | Error | undefined | Present when onSubmit threw |
validationErrors | FieldError[] | undefined | Present when validation failed |
Three outcomes:
success: true—onSubmitcompleted without errorsuccess: false, error—onSubmitthrew an errorsuccess: false, validationErrors— validation failed,onSubmitwas never called
on: {
postSubmit: (event) => {
if (event.success) {
toast.success("Form submitted!");
router.push("/dashboard");
} else if (event.error) {
toast.error(`Submission failed: ${event.error.message}`);
} else if (event.validationErrors) {
toast.warning(`Please fix ${event.validationErrors.length} error(s)`);
}
},
}
pageChanged
Fires when the wizard page changes via nextPage(), previousPage(), or goToPage(). Does not fire on initial render or when pages are automatically clamped (e.g., when a page becomes hidden).
| Property | Type | Description |
|---|---|---|
fromIndex | number | Previous page index |
toIndex | number | New page index |
page | PageState | The new current page |
on: {
pageChanged: (event) => {
analytics.track("wizard_step", {
from: event.fromIndex,
to: event.toIndex,
pageTitle: event.page.title,
});
window.scrollTo(0, 0);
},
}
formReset
Fires after resetForm() completes. The form state is already back to initial values when this fires. Has no payload. Fires after any fieldChanged events from the reset.
on: {
formReset: () => {
externalCache.clear();
analytics.track("form_reset");
},
}
TypeScript
All event types are exported from @fogpipe/forma-react:
import type {
FormaEventMap, // Map of event names to payload types
FormaEvents, // Type for the declarative `on` option
FormaEventListener, // Listener type for a specific event
} from "@fogpipe/forma-react";
// Type a listener for a specific event
const listener: FormaEventListener<"fieldChanged"> = (event) => {
console.log(event.path, event.value);
};
Common Patterns
Analytics tracking
on: {
fieldChanged: (event) => {
if (event.source === "user") {
analytics.track("field_changed", { field: event.path });
}
},
postSubmit: (event) => {
analytics.track("form_submitted", { success: event.success });
},
}
Token / metadata injection
on: {
preSubmit: async (event) => {
event.data.token = await getAuthToken();
event.data.submittedAt = Date.now();
},
}
Dependent field resets
on: {
fieldChanged: (event) => {
if (event.path === "country") {
forma.setFieldValue("state", "");
forma.setFieldValue("city", "");
}
},
}
Calling setFieldValue inside a fieldChanged handler is safe — a recursion guard prevents infinite loops. The nested setFieldValue call will update the value but will not fire another fieldChanged event.
External state synchronization
on: {
fieldChanged: (event) => {
externalStore.update(event.path, event.value);
},
formReset: () => {
externalStore.reset();
},
}