Form Events
Apply this skill when the user needs side effects tied to the form lifecycle (analytics, autosave, toast notifications, scroll-to-error, etc.).
Subscription API
Two ways to subscribe:
// 1. Via a ref to the form instanceformRef.current.on("submit", (form) => { ... });
// 2. Via useForm() from any descendantconst form = useForm();useEffect(() => { const sub = form?.on("submit", (form) => { ... }); return () => sub?.unsubscribe();}, [form]);form.on(event, callback) returns an EventSubscription with .unsubscribe(). Always unsubscribe in useEffect cleanup to prevent leaks across remounts.
The event catalog
Event names are typed via FormEventType in src/types.ts on GitHub.
| Event | Payload | Fires when |
|---|---|---|
init | (form) | Form is initialized (not currently triggered by the package — reserved for future use / custom dispatch) |
registering | (formControl, form) | A control is about to register (before push) |
register | (formControl, form) | A control has registered |
unregistering | (formControl, form) | (Reserved — currently not dispatched) |
unregister | (formControl, form) | A control has unregistered |
change | (payload) | (Form-level — currently dispatched on form-control level, see below) |
dirty | (isDirty, form) | Form’s overall dirty state changed |
validating | (form) | About to validate (return false from a listener to abort the validation entirely) |
validation | (isValid, validatedInputs, form) | Validation completed |
validControl | (formControl, form) | A specific control transitioned to valid |
invalidControl | (formControl, form) | A specific control transitioned to invalid |
validControls | (validControls[], form) | All controls are valid (debounced) |
invalidControls | (invalidControls[], form) | At least one control is invalid (debounced) |
submitting | (isSubmitting, form) | Form entered or left the in-flight state |
submit | (form) | Form has been submitted (also fires when submitting(false) is called) |
resetting | (form) | About to reset |
reset | (form) | Reset finished |
disable | (isDisabled, form) | form.disable() / form.enable() was called |
Ordering during a normal submit
- User clicks submit.
validating(listeners can veto by returningfalse)- For each control: control’s own validation runs; emits
validControl/invalidControlper control. validationwith the boolean outcome + array of validated inputs.validControlsorinvalidControls(debounced 0ms tick).- If invalid, the form’s
onErrorprop is called (not an event subscription). - If valid:
submitting(true)→ firessubmitting.onSubmitprop is called. - After
onSubmitreturns:submitevent fires. - (If
form.submitting(false)is later called from the consumer code)submittingfires again withfalse, thensubmitfires again.
This means submit can fire twice for one user action — once at the end of the synchronous submission flow and once when in-flight completes. Listeners should be idempotent.
Form control events (per-input)
Each form control also dispatches its own events on formControl.onChange, formControl.onDestroy, formControl.onReset. Subscribe from within a custom input:
const { formControl } = useFormControl(props);
useEffect(() => { const subs = [ formControl.onChange(({ value }) => console.log("changed", value)), formControl.onReset(() => console.log("reset")), formControl.onDestroy(() => console.log("destroyed")), ]; return () => subs.forEach((s) => s.unsubscribe());}, [formControl]);Recipes
Autosave on dirty change (debounced)
useEffect(() => { if (!form) return; let timer: ReturnType<typeof setTimeout>; const sub = form.on("dirty", (isDirty) => { if (!isDirty) return; clearTimeout(timer); timer = setTimeout(() => persist(form.values()), 1000); }); return () => { clearTimeout(timer); sub.unsubscribe(); };}, [form]);Scroll to first invalid input
useEffect(() => { if (!form) return; const sub = form.on("invalidControls", (invalidControls) => { invalidControls[0]?.inputRef?.current?.scrollIntoView({ behavior: "smooth" }); }); return () => sub.unsubscribe();}, [form]);Block submission conditionally
form.on("validating", () => { if (!networkReachable()) { showToast("You're offline"); return false; // <-- aborts the validation, onSubmit will NOT fire }});Returning false from a validating listener is the only event-level veto in the system.
Analytics — track form completion
form.on("submit", () => analytics.track("form_submitted", { id: form.id }));form.on("invalidControls", (invalidControls) => { analytics.track("form_validation_failed", { id: form.id, fields: invalidControls.map((c) => c.name), });});Active form globals (alternative to subscriptions)
getActiveForm() returns the most recently mounted form, and getForm(id) returns by id. Useful for hooking into form state from non-React code (background sync, deep links, etc.). Mind that activeForm can be null when nothing is mounted.
Anti-patterns
- Subscribing without unsubscribing — leaks on remount, causes duplicate handler invocations after navigation.
- Mutating
form.values()results inside a listener and expecting controls to update —values()returns a plain object; control state is owned by the controls themselves. Useform.change(name, value)orformControl.change(value). - Relying on
submitfiring exactly once — it fires after submission AND aftersubmitting(false). Listeners must be safe to run more than once.