Skip to main content

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

EventWhen it fires
fieldChangedAfter a field value changes via user input, setValues, or resetForm
preSubmitAt the start of submitForm(), before validation
postSubmitAfter submission completes (success, error, or invalid)
pageChangedWhen the wizard page changes via navigation methods
formResetAfter 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.

PropertyTypeDescription
pathstringField path (e.g., "age" or "items[0].name")
valueunknownNew value
previousValueunknownValue before the change
source"user" | "reset" | "setValues"What triggered the change

Source values:

  • "user" — from setFieldValue (covers both user input via component onChange and direct programmatic calls)
  • "setValues" — from setValues batch update (fires once per field that changed)
  • "reset" — from resetForm (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.

PropertyTypeDescription
dataRecord<string, unknown>Mutable — add/modify fields here
computedRecord<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.

PropertyTypeDescription
dataRecord<string, unknown>The submitted data (reflects preSubmit mutations)
successbooleanWhether submission succeeded
errorError | undefinedPresent when onSubmit threw
validationErrorsFieldError[] | undefinedPresent when validation failed

Three outcomes:

  • success: trueonSubmit completed without error
  • success: false, erroronSubmit threw an error
  • success: false, validationErrors — validation failed, onSubmit was 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).

PropertyTypeDescription
fromIndexnumberPrevious page index
toIndexnumberNew page index
pagePageStateThe 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", "");
}
},
}
note

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();
},
}