submitly

The provider and resolution order

How CrudProvider supplies notify, refresh, navigation and result mapping, and in what order settings are resolved.

submitly depends on no toast library, no router and no data-fetching library. CrudProvider is where you plug them in, once, for the whole app:

<CrudProvider
  notify={{
    success: (result) => toast.success(result.message),
    error: (result) => toast.error(result.message),
  }}
  onMutated={() => queryClient.invalidateQueries()}
  navigate={(to) => navigate(to)}
  normalize={fromMyApi}
  mapError={fromAxiosError}
  isInterrupt={isMyRedirectError}
  onError={(result) => {
    if (result.statusCode === 401) navigate("/login");
  }}
>
  {children}
</CrudProvider>

Every prop is optional. Without a provider at all, the hooks use the built-in defaults.

PropPurposeBuilt-in default
notify{ success, error } handlers, called with the ActionResult when it has a message.No-ops.
onMutatedCalled after every successful mutation: refetch, invalidate queries, router.refresh().No-op.
navigateUsed by redirectTo.window.location.assign(to) (a full page load).
normalizeTurns whatever an action returned into an ActionResult.ActionResult-shaped values pass through; anything else becomes { success: true, data }.
mapErrorTurns a thrown error into an ActionResult.A failed result carrying the error's message.
isInterruptIdentifies thrown values the framework is already handling (for example a Next.js redirect()).Nothing is an interrupt.
onErrorCalled for every failed result, after notify.error and before the per-call onError. Never called for interrupts.No-op.

Resolution order

Wherever a setting can be given in more than one place, the order is always:

per-call option, then provider, then built-in default.

SettingPer call (hook option)ProviderBuilt-in default
Notificationsnotify: false silences this call; notify: { success?, error? } overrides per handlernotifyNo-op
Refresh after successonMutated: false skips it; onMutated: fn replaces itonMutatedNo-op
NavigationredirectTo (a string, or a function of the result) picks the targetnavigate runs itwindow.location.assign
Result mappingnormalizenormalizedefaultNormalize
Error mappingmapErrormapErrordefaultMapError
Interrupt detectionnoneisInterrupt() => false
Failure hookonError runs after the provider's; it adds to it, it does not replace itonErrorNo-op

Per-call options live on both useCrudForm and useCrudAction:

useCrudAction({
  action: archiveProduct,
  notify: { success: (result) => console.log(result.message) }, // only this call
  onMutated: false, // the server action already revalidated
  mapError: fromThatOneLegacyEndpoint,
});

Per-call notify is resolved per handler: notify: { success } replaces only the success handler for that call, while errors still go to the provider's error handler.

Failure handling in one place

A per-call onError is right for what one form or button does on failure. For a reaction every failure should have, use the provider's onError. It receives the failed ActionResult for every non-interrupt failure from useCrudForm and useCrudAction (a returned { success: false } and a mapped thrown error alike), so a single check covers the whole app. A typical use is sending an expired session to a login page:

<CrudProvider
  notify={{
    // The navigation below is the feedback for a 401, so no error toast for it.
    error: (result) => {
      if (result.statusCode !== 401) toast.error(result.message);
    },
  }}
  onError={(result) => {
    if (result.statusCode === 401) navigate("/login");
  }}
>
  {children}
</CrudProvider>

The order on failure is always: notify.error, the form or action state (serverErrors, field errors), the provider's onError, then the per-call onError (see the failure path). Both onErrors run; neither replaces the other. Thrown values that isInterrupt recognises skip all of them.

If you only need to stop the hooks from doing anything (a redirect the framework already performs), use isInterrupt instead. If you want to navigate after a success, use redirectTo.

Nested providers

Providers can be nested, for example to give one section of the app different behaviour. A child overrides its parent prop by prop, and notify is merged per key:

<CrudProvider notify={{ success: toastSuccess, error: toastError }} onMutated={refetchAll}>
  <Dashboard />

  <CrudProvider notify={{ success: showBanner }} onMutated={refetchOrders}>
    {/* success -> showBanner, error -> toastError (inherited), onMutated -> refetchOrders */}
    <OrdersPage />
  </CrudProvider>
</CrudProvider>

A prop you leave out (or pass as undefined) is inherited. To switch an inherited handler off, pass a no-op (() => {}) rather than undefined.

Interrupts

Some thrown values are not errors. A Next.js server action that calls redirect() throws a special error to unwind the stack, and the framework then navigates. isInterrupt tells the hooks to stop silently for such values: no notify, no serverErrors, no onError, no callbacks. The dialog lock is released, and useCrudAction's execute resolves with a bare { success: false }.

NextCrudProvider sets isInterrupt to isNextInterrupt for you. In a single-page app you can use it for your own control-flow errors, for example a "session expired" error that your API client already handled by redirecting to a login page.

Reading the configuration

useCrudConfig() returns the fully resolved configuration (every field filled in) for custom hooks that want the same conventions. defaultCrudConfig is the value used when there is no provider. Both are listed in the API reference.

Performance note

The context value is rebuilt when any prop's identity changes, which re-renders every submitly hook below the provider. Behaviour is unaffected, because the hooks always call the latest handlers. To avoid the extra renders, define handler props with useCallback or hoist them out of the component, instead of creating inline arrow functions on every render.

The Next.js preset

NextCrudProvider from submitly/next is a CrudProvider with onMutated bound to router.refresh(), navigate bound to router.push() and isInterrupt bound to isNextInterrupt. Any prop you pass wins over the preset. See the Next.js quick start and submitly/next.

Edit on GitHub

On this page