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.
| Export | Kind |
|---|---|
CrudProvider, useCrudConfig, defaultCrudConfig | Provider and configuration |
useCrudForm | Hook |
useCrudAction | Hook |
useControllableDialog | Hook |
useDialogLockScope, DialogLockProvider, useDialogLock, useDialogLockHandle | Dialog lock |
isActionResult, defaultNormalize, defaultMapError, getDotPath, issuesToErrors, validate | Helpers, re-exported from submitly/core |
ActionResult, FieldErrorMap, Normalize, MapError, NotifyHandlers, InferResultData | Types, re-exported from submitly/core |
MutationOptions, CrudConfig, CrudProviderProps, UseCrudFormOptions, UseCrudFormReturn, UseCrudActionOptions, UseCrudActionReturn, UseControllableDialogOptions, UseControllableDialogReturn, DialogLockScope | Types |
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.
| Option | Type | Default | Description |
|---|---|---|---|
schema | StandardSchemaV1<TFieldValues, TOutput> | required | Any Standard Schema: Zod 3.24+ / 4, Valibot 1, ArkType 2, ... Used as the react-hook-form resolver. |
defaultValues | DefaultValues<TFieldValues> | undefined | Initial 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> | required | Receives the schema's parsed output (after transforms). Any async function. Its return value goes through normalize; a thrown error goes through mapError. |
resetOnSuccess | boolean | true | Reset the form to defaultValues after a successful result. |
formOptions | Omit<UseFormProps<TFieldValues, unknown, TOutput>, "resolver" | "defaultValues"> | undefined | Extra useForm options such as mode or reValidateMode. |
onSuccess | (result: ActionResult<TData>) => void | undefined | See MutationOptions. |
onError | (result: ActionResult<TData>) => void | undefined | See MutationOptions. |
redirectTo | string | ((result) => string | null | undefined) | undefined | See MutationOptions. |
notify | false | NotifyHandlers | provider's | See MutationOptions. |
onMutated | false | (() => void) | provider's | See MutationOptions. |
normalize | Normalize | provider's | See MutationOptions. |
mapError | MapError | provider's | See MutationOptions. |
lockDialog | boolean | true | See MutationOptions. |
Returns (UseCrudFormReturn)
| Property | Type | Description |
|---|---|---|
form | UseFormReturn<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. |
serverErrors | string[] | 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 | () => void | Empties serverErrors. |
isSubmitting | boolean | react-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.
| Option | Type | Default | Description |
|---|---|---|---|
action | (input: TInput) => Promise<TResult> | required | Any 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)
| Property | Type | Description |
|---|---|---|
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. |
isPending | boolean | True while a call is in flight. |
serverErrors | string[] | result.errors of the last failed call. Cleared when the next call starts. |
clearServerErrors | () => void | Empties 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)
| Option | Type | Default | Description |
|---|---|---|---|
open | boolean | undefined | Controlled open state. Leave it undefined for an uncontrolled dialog. |
defaultOpen | boolean | false | Initial state when uncontrolled. |
onOpenChange | (open: boolean) => void | undefined | Called on every requested change, controlled or not. |
locked | boolean | (() => boolean) | false | While true, every open-state change is ignored. Pass a function (such as useDialogLockScope().isLocked) to read it at call time. |
Returns (UseControllableDialogReturn)
| Property | Type | Description |
|---|---|---|
open | boolean | The current open state (the open option when controlled, internal state otherwise). |
setOpen | (open: boolean) => void | Requests a change. Ignored while locked. Updates internal state when uncontrolled, and always calls onOpenChange. |
openKey | number | Increments on every closed-to-open transition, controlled or not. Use it as the key of the form inside the dialog. |
isControlled | boolean | True 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
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | Content that can use the hooks. | |
notify | NotifyHandlers | no-ops | { success, error } handlers, called with the ActionResult when it has a message. Merged per key with a parent provider. |
onMutated | () => void | no-op | Called after every successful mutation (refetch, invalidate queries, router.refresh()). |
navigate | (to: string) => void | window.location.assign | Used for redirectTo. |
normalize | Normalize | defaultNormalize | Turns whatever an action returned into an ActionResult. |
mapError | MapError | defaultMapError | Turns a thrown error into an ActionResult. |
isInterrupt | (error: unknown) => boolean | () => false | Identifies thrown values the framework is already handling (such as a Next.js redirect()). The hooks then stop silently. |
onError | (result: ActionResult) => void | no-op | Called 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.
| Property | Type | Description |
|---|---|---|
locked | boolean | React state. Use it for rendering, such as disabling a close button. |
isLocked | () => boolean | Synchronous read, current even before the owner re-renders. Use it for guards, such as useControllableDialog's locked. Stable identity. |
scope | DialogLockScope | Pass 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.
| Option | Type | Default | Description |
|---|---|---|---|
onSuccess | (result: ActionResult<TData>) => void | undefined | Called after a successful result, after notify, onMutated and (for forms) the reset. |
onError | (result: ActionResult<TData>) => void | undefined | Called 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. |
redirectTo | string | ((result) => string | null | undefined) | undefined | Navigate after success through the provider's navigate. A function receives the result and may return null or undefined to skip the redirect. |
notify | false | NotifyHandlers | the provider's notify | false silences notifications for this call. An object overrides the success and/or error handler for this call. |
onMutated | false | (() => void) | the provider's onMutated | false skips it (for example, the server action already revalidated). A function replaces the provider's. |
normalize | Normalize | the provider's normalize | Overrides result normalization for this call. |
mapError | MapError | the provider's mapError | Overrides error mapping for this call. |
lockDialog | boolean | true | Lock 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:
| Export | Kind |
|---|---|
isActionResult, defaultNormalize, defaultMapError | Result helpers |
getDotPath, issuesToErrors, validate | Schema helpers |
ActionResult, FieldErrorMap, Normalize, MapError, NotifyHandlers, InferResultData | Types |
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.