submitly

submitly (main entry)

Every export of the main entry point, with options, defaults and return values.

import { useCrudForm, useCrudAction, CrudProvider /* ... */ } from "submitly";

The main entry point has no dependency on Next.js. It is a client module ("use client"): it can be imported from a Server Component tree to render its components, but its functions can only be called from client code. The pure helpers and types it re-exports (defaultNormalize, issuesToErrors, ActionResult, ...) live in submitly/core, which has no "use client" directive: import them from there in server code.

Hooks

useCrudForm

function useCrudForm<TFieldValues extends FieldValues, TOutput = TFieldValues, TResult = unknown>(
  options: UseCrudFormOptions<TFieldValues, TOutput, TResult>,
): UseCrudFormReturn<TFieldValues, TOutput>;

react-hook-form, Standard Schema validation and action submission, with notify / refresh / reset / redirect on success and server errors (form-level and per field) on failure. The order of every step is described in The submit lifecycle.

TFieldValues is the schema's input type (what the form fields hold), TOutput its output type (what action receives), and TResult the action's resolved type, from which TData is inferred for the callbacks.

Options (UseCrudFormOptions)

UseCrudFormOptions is the options below plus every MutationOptions field.

OptionTypeDefaultDescription
schemaStandardSchemaV1<TFieldValues, TOutput>requiredAny Standard Schema: Zod 3.24+ / 4, Valibot 1, ArkType 2, ... Used as the react-hook-form resolver.
defaultValuesDefaultValues<TFieldValues>undefinedInitial values. Also what the form is reset to after success. Read by react-hook-form on mount only; remount with a key to load different values.
action(values: TOutput) => Promise<TResult>requiredReceives the schema's parsed output (after transforms). Any async function. Its return value goes through normalize; a thrown error goes through mapError.
resetOnSuccessbooleantrueReset the form to defaultValues after a successful result.
formOptionsOmit<UseFormProps<TFieldValues, unknown, TOutput>, "resolver" | "defaultValues">undefinedExtra useForm options such as mode or reValidateMode.
onSuccess(result: ActionResult<TData>) => voidundefinedSee MutationOptions.
onError(result: ActionResult<TData>) => voidundefinedSee MutationOptions.
redirectTostring | ((result) => string | null | undefined)undefinedSee MutationOptions.
notifyfalse | NotifyHandlersprovider'sSee MutationOptions.
onMutatedfalse | (() => void)provider'sSee MutationOptions.
normalizeNormalizeprovider'sSee MutationOptions.
mapErrorMapErrorprovider'sSee MutationOptions.
lockDialogbooleantrueSee MutationOptions.

Returns (UseCrudFormReturn)

PropertyTypeDescription
formUseFormReturn<TFieldValues, unknown, TOutput>The regular react-hook-form object: register, control, formState, setValue, reset, ...
onSubmit(event?: BaseSyntheticEvent) => Promise<void>Pass it to <form onSubmit>. The action's own errors and failures never reject it (they become serverErrors and field errors), but an error thrown by your own code propagates: a callback, mapError, isInterrupt, or a schema whose validate() throws (see what is guarded). Ignores submits while one is in flight.
serverErrorsstring[]Form-level errors from the last failed result: result.errors, plus fieldErrors entries with no usable key. Cleared on the next submit, once client validation passes and just before the action is called.
clearServerErrors() => voidEmpties serverErrors.
isSubmittingbooleanreact-hook-form's formState.isSubmitting, true while the submit (validation, action and callbacks) is running.
const { form, onSubmit, serverErrors, isSubmitting } = useCrudForm({
  schema,
  defaultValues: { name: "" },
  action: (values) => saveProduct(values),
  onSuccess: (result) => close(),
});

useCrudAction

function useCrudAction<TInput = void, TResult = unknown>(
  options: UseCrudActionOptions<TInput, TResult>,
): UseCrudActionReturn<TInput, InferResultData<TResult>>;

A non-form mutation (delete, toggle, bulk action) with the same notify / refresh / redirect / error conventions as useCrudForm. With TInput = void, call execute() without an argument.

Options (UseCrudActionOptions)

UseCrudActionOptions is the option below plus every MutationOptions field.

OptionTypeDefaultDescription
action(input: TInput) => Promise<TResult>requiredAny async function: a server action, a fetch wrapper, an RPC call.

The inherited options (onSuccess, onError, redirectTo, notify, onMutated, normalize, mapError, lockDialog) behave as described under MutationOptions.

Returns (UseCrudActionReturn)

PropertyTypeDescription
execute(input: TInput) => Promise<ActionResult<TData>>Runs the action. The action's own errors and failures never reject: thrown errors go through mapError, and an interrupt resolves with a bare { success: false } and no side effects. An error thrown by your own code (the callbacks onSuccess, onError, notify handlers, onMutated, navigate, the provider's onError, and also mapError and isInterrupt) is not caught and rejects the promise (see what is guarded). While a call is in flight, further calls return the same promise. Stable identity across renders.
isPendingbooleanTrue while a call is in flight.
serverErrorsstring[]result.errors of the last failed call. Cleared when the next call starts.
clearServerErrors() => voidEmpties serverErrors.
const { execute, isPending } = useCrudAction({
  action: (id: number) => deleteProduct(id),
  onSuccess: () => close(),
});

<button disabled={isPending} onClick={() => execute(row.id)}>Delete</button>;

useControllableDialog

function useControllableDialog(options?: UseControllableDialogOptions): UseControllableDialogReturn;

Open state for any dialog, controlled or uncontrolled, that ignores every change while locked and tells you when to remount the form inside it.

Options (UseControllableDialogOptions)

OptionTypeDefaultDescription
openbooleanundefinedControlled open state. Leave it undefined for an uncontrolled dialog.
defaultOpenbooleanfalseInitial state when uncontrolled.
onOpenChange(open: boolean) => voidundefinedCalled on every requested change, controlled or not.
lockedboolean | (() => boolean)falseWhile true, every open-state change is ignored. Pass a function (such as useDialogLockScope().isLocked) to read it at call time.

Returns (UseControllableDialogReturn)

PropertyTypeDescription
openbooleanThe current open state (the open option when controlled, internal state otherwise).
setOpen(open: boolean) => voidRequests a change. Ignored while locked. Updates internal state when uncontrolled, and always calls onOpenChange.
openKeynumberIncrements on every closed-to-open transition, controlled or not. Use it as the key of the form inside the dialog.
isControlledbooleanTrue when the open option is defined.

Provider

CrudProvider

function CrudProvider(props: CrudProviderProps): JSX.Element;

Supplies notify, refresh, navigation and result mapping to every submitly hook below it. All props are optional. Nested providers override their parent prop by prop, and notify merges per key. See The provider and resolution order.

CrudProviderProps

PropTypeDefaultDescription
childrenReactNodeContent that can use the hooks.
notifyNotifyHandlersno-ops{ success, error } handlers, called with the ActionResult when it has a message. Merged per key with a parent provider.
onMutated() => voidno-opCalled after every successful mutation (refetch, invalidate queries, router.refresh()).
navigate(to: string) => voidwindow.location.assignUsed for redirectTo.
normalizeNormalizedefaultNormalizeTurns whatever an action returned into an ActionResult.
mapErrorMapErrordefaultMapErrorTurns a thrown error into an ActionResult.
isInterrupt(error: unknown) => boolean() => falseIdentifies thrown values the framework is already handling (such as a Next.js redirect()). The hooks then stop silently.
onError(result: ActionResult) => voidno-opCalled for every failed result from useCrudForm and useCrudAction (a returned { success: false } or a mapped thrown error), after notify.error and before the per-call onError. Never called for interrupts. For cross-cutting failure handling such as redirecting to a login page on a 401. Overrides a parent provider's onError.

useCrudConfig

function useCrudConfig(): CrudConfig;

Returns the fully resolved configuration: built-in defaults when there is no provider, otherwise the merge of every enclosing provider. Useful for custom hooks that want the same conventions.

defaultCrudConfig

const defaultCrudConfig: CrudConfig;

The configuration used when there is no provider: no-op notify and onMutated, navigate via window.location.assign (guarded for the server), defaultNormalize, defaultMapError, no interrupts, and a no-op onError.

CrudConfig

type CrudConfig = {
  notify: Required<NotifyHandlers>;
  onMutated: () => void;
  navigate: (to: string) => void;
  normalize: Normalize;
  mapError: MapError;
  isInterrupt: (error: unknown) => boolean;
  onError: (result: ActionResult) => void;
};

The resolved configuration, as read by the hooks. Unlike CrudProviderProps, every field is present.

Dialog lock

See The dialog lock for how the pieces fit together.

useDialogLockScope

function useDialogLockScope(): {
  locked: boolean;
  isLocked: () => boolean;
  scope: DialogLockScope;
};

The owner side, used inside a dialog component. The dialog is locked while any holder below the matching DialogLockProvider holds the lock.

PropertyTypeDescription
lockedbooleanReact state. Use it for rendering, such as disabling a close button.
isLocked() => booleanSynchronous read, current even before the owner re-renders. Use it for guards, such as useControllableDialog's locked. Stable identity.
scopeDialogLockScopePass it to DialogLockProvider. Stable identity.

DialogLockProvider

function DialogLockProvider(props: { scope: DialogLockScope; children?: ReactNode }): JSX.Element;

Makes scope available to the hooks below it. Outside a provider, locking is a no-op.

DialogLockScope

type DialogLockScope = {
  setLock: (id: string, locked: boolean) => void;
  isLocked: () => boolean;
};

The object useDialogLockScope creates and the lock hooks use. You normally pass it straight to DialogLockProvider.

useDialogLock

function useDialogLock(locked: boolean): void;

Locks the nearest enclosing dialog (no close, escape or overlay click) while locked is true. A no-op outside a dialog. useCrudForm and useCrudAction lock automatically while pending.

useDialogLockHandle

function useDialogLockHandle(): (locked: boolean) => void;

Returns a stable imperative setter for the nearest dialog's lock, for custom pending states. The lock is released automatically on unmount. A no-op outside a dialog.

Shared options and types

MutationOptions

type MutationOptions<TData> = {
  onSuccess?: (result: ActionResult<TData>) => void;
  onError?: (result: ActionResult<TData>) => void;
  redirectTo?: string | ((result: ActionResult<TData>) => string | null | undefined);
  notify?: false | NotifyHandlers;
  onMutated?: false | (() => void);
  normalize?: Normalize;
  mapError?: MapError;
  lockDialog?: boolean;
};

The options useCrudForm and useCrudAction share. Each overrides the provider for that call.

OptionTypeDefaultDescription
onSuccess(result: ActionResult<TData>) => voidundefinedCalled after a successful result, after notify, onMutated and (for forms) the reset.
onError(result: ActionResult<TData>) => voidundefinedCalled after a failed result, after notify.error and the provider's onError. Not called for interrupts. It adds to the provider's onError; it does not replace it. If it throws, the error propagates out of onSubmit / execute.
redirectTostring | ((result) => string | null | undefined)undefinedNavigate after success through the provider's navigate. A function receives the result and may return null or undefined to skip the redirect.
notifyfalse | NotifyHandlersthe provider's notifyfalse silences notifications for this call. An object overrides the success and/or error handler for this call.
onMutatedfalse | (() => void)the provider's onMutatedfalse skips it (for example, the server action already revalidated). A function replaces the provider's.
normalizeNormalizethe provider's normalizeOverrides result normalization for this call.
mapErrorMapErrorthe provider's mapErrorOverrides error mapping for this call.
lockDialogbooleantrueLock the enclosing dialog while pending.

Re-exported from submitly/core

The root entry re-exports everything in submitly/core for backwards compatibility, so these imports are equivalent in client code. They are documented on the submitly/core page:

In server code (Server Components, server actions, route handlers) import them from submitly/core: functions imported from the client submitly entry cannot be called on the server.

Edit on GitHub

On this page