submitly

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

OrderStepNotes
1notify.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.
2onMutated()Per-call onMutated replaces the provider's; false skips it.
3Form resetform.reset(defaultValues). Skipped with resetOnSuccess: false.
4onSuccess(result)Your callback.
5RedirectredirectTo (a string, or a function of the result that may return null / undefined to skip) is passed to the provider's navigate.

Failure path

OrderStepNotes
1notify.error(result)Only when result.message is set and notify is not false.
2serverErrors is setresult.errors, plus any fieldErrors entries whose key is empty.
3Field errors appliedOne form.setError(name, { type: "server", ... }) per entry in result.fieldErrors; the first one is focused.
4Provider onError(result)The provider's failure hook. Runs for every non-interrupt failure.
5onError(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:

  1. 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).
  2. serverErrors is cleared, isPending becomes true, and the dialog is locked (unless lockDialog: false).
  3. Call the action, with normalize / mapError / isInterrupt as above. The action cannot make execute reject.
  4. Release the dialog lock.
  5. Interrupt: resolve with { success: false } and stop.
  6. notify (success or error handler), when the result has a message.
  7. Success: onMutated(), then onSuccess(result), then the redirect. Failure: serverErrors becomes result.errors ?? [], then the provider's onError(result), then your per-call onError(result).
  8. isPending returns to false; execute resolves with the ActionResult.

useCrudAction never touches fieldErrors: it has no form to apply them to.

Guarantees and boundaries

useCrudFormuseCrudAction
Concurrent callsThe second submit is ignored.The second call returns the first call's promise.
IdentityNot part of the contract.execute is stable across renders.
Pending flagisSubmitting (react-hook-form's, true from submit until the pipeline settles)isPending
Latest optionsCallbacks and options are read when they run, so a stale closure never fires.Same.
Errors from action or normalizeCaught, 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 an ActionResult and take the failure path above. onSubmit / execute resolve.
  • Does propagate: an error thrown by your own code: the callbacks onSuccess, a per-call onError, a notify handler, onMutated, navigate (or redirectTo when it is a function), and the provider's onError; also mapError (per call or provider, which runs when the action throws) and isInterrupt. For useCrudForm, a schema whose own validate() throws instead of returning issues rejects onSubmit as well, and the action is not called. submitly does not swallow any of these. onSubmit / execute reject 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:

  1. The user clicks Save. The dialog locks; escape, overlay click and the close button do nothing.
  2. The server answers { success: true, message: "Created" }.
  3. The lock is released. A toast shows "Created". The list refreshes (onMutated). The form resets.
  4. Your onSuccess closes the dialog. Because the lock is already released, setOpen(false) is honoured.
  5. The next time the dialog opens, openKey has changed, so the form remounts with fresh default values.
Edit on GitHub

On this page