The submit lifecycle
The exact order of validation, notify, onMutated, reset, onSuccess and redirect, the failure path, and the guards around them.
useCrudForm and useCrudAction run the same pipeline. This page lists it step by step, so you know exactly when each
callback fires.
useCrudForm: submit
Guard
If a submit is already in flight, the new one is ignored: the event's default is prevented and nothing else happens.
Otherwise the form marks itself in flight and locks the nearest dialog (unless lockDialog: false).
Validate
react-hook-form runs the schema through the built-in Standard Schema resolver. If validation fails, react-hook-form sets the field errors, the action is not called, nothing is notified, and the lock is released.
Call the action
serverErrors is cleared. The action receives the schema's parsed output (after transforms). The return value goes
through normalize. A thrown error goes through mapError, unless isInterrupt recognises it. Either way the action
cannot make the call reject: the outcome is always an ActionResult. (Errors thrown by your own callbacks later in
the pipeline do propagate; see Guarantees and boundaries.)
Release the dialog lock
The lock is released before any callback runs, so an onSuccess that closes the dialog is not blocked by its own lock.
Then the path forks on result.success.
Success path
| Order | Step | Notes |
|---|---|---|
| 1 | notify.success(result) | Only when result.message is set and notify is not false. A per-call notify.success replaces the provider's notify.success for that call; it does not add to it. |
| 2 | onMutated() | Per-call onMutated replaces the provider's; false skips it. |
| 3 | Form reset | form.reset(defaultValues). Skipped with resetOnSuccess: false. |
| 4 | onSuccess(result) | Your callback. |
| 5 | Redirect | redirectTo (a string, or a function of the result that may return null / undefined to skip) is passed to the provider's navigate. |
Failure path
| Order | Step | Notes |
|---|---|---|
| 1 | notify.error(result) | Only when result.message is set and notify is not false. |
| 2 | serverErrors is set | result.errors, plus any fieldErrors entries whose key is empty. |
| 3 | Field errors applied | One form.setError(name, { type: "server", ... }) per entry in result.fieldErrors; the first one is focused. |
| 4 | Provider onError(result) | The provider's failure hook. Runs for every non-interrupt failure. |
| 5 | onError(result) | Your per-call callback. Runs after the provider's; it does not replace it. |
There is no onMutated, reset or redirect on failure: the form keeps the user's input so they can fix it.
Interrupts
If the action throws a value for which isInterrupt returns true, the pipeline stops after the lock is released. No
notify, no serverErrors, no provider or per-call onError, no reset, no redirect. See Interrupts.
useCrudAction: execute
execute(input) follows the same shape, without validation, fields or reset:
- Guard. If a call is in flight, return the same promise (a double click fires the action once, and both callers get the same result).
serverErrorsis cleared,isPendingbecomestrue, and the dialog is locked (unlesslockDialog: false).- Call the action, with
normalize/mapError/isInterruptas above. The action cannot makeexecutereject. - Release the dialog lock.
- Interrupt: resolve with
{ success: false }and stop. notify(success or error handler), when the result has amessage.- Success:
onMutated(), thenonSuccess(result), then the redirect. Failure:serverErrorsbecomesresult.errors ?? [], then the provider'sonError(result), then your per-callonError(result). isPendingreturns tofalse;executeresolves with theActionResult.
useCrudAction never touches fieldErrors: it has no form to apply them to.
Guarantees and boundaries
useCrudForm | useCrudAction | |
|---|---|---|
| Concurrent calls | The second submit is ignored. | The second call returns the first call's promise. |
| Identity | Not part of the contract. | execute is stable across renders. |
| Pending flag | isSubmitting (react-hook-form's, true from submit until the pipeline settles) | isPending |
| Latest options | Callbacks and options are read when they run, so a stale closure never fires. | Same. |
Errors from action or normalize | Caught, mapped by mapError. | Same. |
What "never rejects" means
The guarantee is about the action, and it is precise:
- Never rejects: a thrown error from the action (or from
normalize), a returned{ success: false }, a network failure, a 500. All of them become anActionResultand take the failure path above.onSubmit/executeresolve. - Does propagate: an error thrown by your own code: the callbacks
onSuccess, a per-callonError, anotifyhandler,onMutated,navigate(orredirectTowhen it is a function), and the provider'sonError; alsomapError(per call or provider, which runs when the action throws) andisInterrupt. ForuseCrudForm, a schema whose ownvalidate()throws instead of returning issues rejectsonSubmitas well, and the action is not called. submitly does not swallow any of these.onSubmit/executereject with that error, the remaining steps of the pipeline are skipped, and the pending state and dialog lock are still released.
Keep your callbacks safe, or catch inside them. A notify handler that throws (a toast library that is not mounted,
say) turns a handled failure into a rejected onSubmit, which React reports as an unhandled error from the event
handler.
Typical sequence
A successful create-in-a-dialog, as the user experiences it:
- The user clicks Save. The dialog locks; escape, overlay click and the close button do nothing.
- The server answers
{ success: true, message: "Created" }. - The lock is released. A toast shows "Created". The list refreshes (
onMutated). The form resets. - Your
onSuccesscloses the dialog. Because the lock is already released,setOpen(false)is honoured. - The next time the dialog opens,
openKeyhas changed, so the form remounts with fresh default values.