Skip to content

Lifecycle and hooks

Submission lifecycle and form state axesHooks run in order: beforeValidate, afterValidate, beforeSubmit, the transport, then afterSubmit or onSubmitError, then report fetches with afterReportFetch or onReportFetchError. During this the operation axis moves from idle to validating to submitting and back to idle, and submissionStatus ends as succeeded, failed, or aborted. Separately, lifecycle moves between active and suspended through suspend and resume, and ends at disposed.HOOKS, IN ORDERbeforeValidateafterValidatebeforeSubmittransportafterSubmitoronSubmitErrorafterReportFetchoronReportFetchErrorSTATE.OPERATIONvalidatingsubmittingidleSTATE.SUBMISSIONSTATUSprevious outcome is kept while editingsucceeded · failed · abortedSTATE.LIFECYCLEactivesuspendeddisposedsuspend()resume()dispose() · unmount()

A form’s state has three independent axes, plus per-field flags:

Axis Values Meaning
state.lifecycle active, suspended, disposed Whether the form accepts work. disposed is final.
state.operation idle, validating, submitting Work in progress right now.
state.submissionStatus idle, succeeded, failed, aborted Outcome of the latest submission.

Editing is tracked separately with dirty and touched, so a succeeded status stays visible while the user edits again; it changes when the next submission starts or on reset().

Pass hooks to mountForm, createFormView, or createForm:

mountForm(container, {
schema,
transport,
hooks: {
beforeValidate: ({ values, submitCount }) => {},
afterValidate: ({ result }) => {},
beforeSubmit: ({ backend, modelValues, displayValues, inputs, submitCount, signal }) => {},
afterSubmit: ({ result }) => analytics.track("forecast", result.meta),
onSubmitError: ({ error }) => console.error(error),
afterReportFetch: ({ reportId, payload }) => {},
onReportFetchError: ({ reportId, error }) => {},
},
});
Hook Runs If it throws
beforeValidate, afterValidate Around every validation, including the one inside a submission. The validation fails.
beforeSubmit After validation, before the transport. Receives copies of the records. The submission fails before the transport.
afterSubmit After reports are updated. The submission fails, unless hookFailurePolicy: { afterSubmit: "preserve-success" }, which keeps the success and calls onSubmitError.
onSubmitError After a failed submission. The original error is kept; the hook’s error goes to onListenerError.
afterReportFetch, onReportFetchError After each report fetch. The report keeps its outcome; the error goes to onListenerError.

Hooks may return promises, and the operation waits for them; beforeSubmit also receives the submission’s signal. Use hooks for analytics, logging, and side effects you own. To change what is submitted, use a behavior; hooks receive copies.

listenerErrorPolicy decides what happens when a subscribe listener or an observer hook throws: "ignore" (isolate it and report it to onListenerError) or "throw-aggregate" (collect the failures and throw them together after the state change is committed).

For telemetry or debugging, subscribeTransitions reports each change of the axes in order, without values or payloads:

const stop = mounted.form.subscribeTransitions(({ sequence, type, from, to, reason }) => {
telemetry.record(type, { sequence, from, to, reason });
});

type is one of validation-started, validation-finished, validation-failed, submission-started, submission-succeeded, submission-failed, submission-aborted, reset, restored, suspended, resumed, and disposed. Sequence numbers increase for the lifetime of the form. Earlier transitions are not replayed to new subscribers; store them yourself if you need a history.

suspend(reason?) pauses a form without losing it: pending validation, submission, behaviors, and report fetches are aborted, while values, interaction state, completed results, and subscriptions stay. While suspended, the state and snapshots stay readable and changes are rejected. resume() makes the same form active again; aborted work is not restarted. Both are idempotent.

router.beforeLeave(() => mounted.suspend("route hidden"));
router.afterEnter(() => mounted.resume());

mountForm(container, { hostLifecycle: "document" }) suspends on visibilitychange to hidden and on pagehide, and resumes when the page is shown again. The default, "manual", leaves this to you.

Call Effect
form.reset() Values return to their initial values; errors, reports, and the submission outcome are cleared.
form.abortSubmit(reason?) Aborts a running validation or submission; it ends as aborted.
mounted.unmount() Disposes the form, aborts all pending work, disconnects the design system, removes the host, and restores replaced content.
view.dispose(), form.dispose() Disposes a headless form. Publishes lifecycle: "disposed" before releasing subscriptions.

Changing values or resetting while a submission is still validating aborts that submission; a value change during afterSubmit makes the finished submission obsolete, and it rejects with SubmissionAbortedError.