submitly

The ActionResult contract

The one result shape every action resolves to, and how each field is used.

Every action resolves to an ActionResult (after normalize). It is the single contract between your transport (server action, fetch, RPC) and submitly's hooks.

type ActionResult<TData = unknown> = {
  success: boolean;
  message?: string; // passed to notify.success / notify.error when present
  data?: TData;
  errors?: string[]; // form-level errors, exposed as `serverErrors`
  fieldErrors?: Record<string, string[]>; // keyed by dotted path, applied with form.setError
  statusCode?: number;
};

Only success is required, so { success: true } is a valid result.

What each field does

FieldUsed byEffect
successBoth hooksChooses the success path or the failure path.
messagenotifyWhen present, notify.success (on success) or notify.error (on failure) is called with the whole result. No message, no toast.
dataYour callbacksPassed through to onSuccess and onError, and to redirectTo when it is a function. Typed from the action's return type.
errorsBoth hooksForm-level errors. They become serverErrors.
fieldErrorsuseCrudFormEach entry is applied with form.setError, and the first one is focused. Ignored by useCrudAction, which has no fields.
statusCodeYouInformational. runAction sets 422 for validation failures and 500 for unexpected errors; submitly itself never branches on it.

Field errors

fieldErrors is keyed by dotted path, the same format react-hook-form uses for field names:

{
  success: false,
  message: "Could not save the product",
  fieldErrors: {
    name: ["A product with this name already exists"],
    "items.0.sku": ["SKU is required"],
  },
}

For each key, useCrudForm calls form.setError(name, { type: "server", message, types }). message is the first string; types holds all of them, indexed by position ("0", "1", ...), so you can render every message if you want. An entry with an empty key, or an empty list, has no field to attach to: its messages are added to serverErrors instead (empty lists are skipped).

Like any react-hook-form error set with setError, a server error stays until the field is validated again (by default, on the next change after a submit) or you clear it with form.clearErrors.

Returning results

An action can return an ActionResult directly:

async function saveProduct(values: ProductValues) {
  const response = await fetch("/api/products", { method: "POST", body: JSON.stringify(values) });

  if (!response.ok) {
    const body = await response.json();
    return { success: false, message: body.message, fieldErrors: body.fieldErrors };
  }

  return { success: true, message: "Saved", data: await response.json() };
}

Or it can return anything else. With the default normalize:

Action returnsBecomes
An object with a boolean successPassed through unchanged.
undefined (a void action){ success: true }
Any other value, null included{ success: true, data: value }

An action that throws is handled by mapError. The default turns an Error (or a thrown string) into { success: false, message, errors: [message] }, so the failure is never silent even without a toast library. Any other thrown value becomes a bare { success: false } with no message.

Adapt other response shapes with normalize and other error types with mapError, once in the provider or per call. See Axios mapError and Custom API shape.

submitly/core exports the pieces that implement this contract, so you can reuse them in your own adapters (the main submitly entry re-exports them too):

ExportPurpose
ActionResult<TData>The type above.
FieldErrorMapRecord<string, string[]>.
Normalize, MapErrorFunction types for the normalize and mapError options.
isActionResult(value)The check the default normalize uses: an object with a boolean success.
defaultNormalize, defaultMapErrorThe built-in behaviours, handy as a fallback inside your own adapter.
InferResultData<TResult>Extracts TData from an action's return type.

See the submitly/core reference for details.

The main submitly entry point is a client module, so a function imported from it cannot be called from a Server Component, server action or route handler (it fails with "Attempted to call ... from the server"). In server code, import the same helpers and types from submitly/core, which has no "use client" directive and imports no React:

import { defaultMapError, type ActionResult } from "submitly/core";
Edit on GitHub

On this page