Safe Submit

Collapses repeated clicks, Enter spam and double-taps into one request and reuses an idempotency key on retry, so your server can dedupe.

StopsHelps prevent paying twice after a timeout

Installation

pnpm dlx shadcn@latest add https://bollard-ui.vercel.app/r/safe-submit.json

The files land in your project. They're yours to edit.

Usage

safe-submit-usage.tsx
import { SafeSubmit } from "@/components/ui/safe-submit"

<SafeSubmit onSubmit={pay} pendingLabel="Paying…" successLabel="Paid">
  Pay ₹2,499
</SafeSubmit>

What it catches

  • Repeat clicks and Enter presses while a request is in flight (synchronous guard)
  • Double-taps right after a request settles (cooldown, default 400 ms)
  • Ambiguous failures (network, timeout, 408/429/5xx), retried with the same key
  • Definite failures, which mint a fresh key for the next attempt

Props

SafeSubmit props
PropTypeDefault
onSubmit* (required)

Does the work. Send `idempotencyKey` as an `Idempotency-Key` header. `formData` is the enclosing form's data.

(submission: { idempotencyKey: string; formData?: FormData }) => Promise<unknown>—
variant

Visual style of the button.

keyof typeof variants"default"
size

Size of the button.

keyof typeof sizes"default"
pendingLabel

Label while the request is in flight.

React.ReactNode"Submitting…"
successLabel

Label shown briefly after success.

React.ReactNode"Done"
retryLabel

Label after a failure.

React.ReactNode"Retry"
retrySafeHint

Shown after a retryable failure (network, timeout, 5xx).

React.ReactNode"Retrying is safe: it reuses the same request, so nothing…
retryUnsafeHint

Shown after a definite rejection (validation, 4xx).

React.ReactNode"Fix the issue, then try again."
errorMessage

Fallback when the error has no message.

string"Something went wrong"
resetAfter

Milliseconds the success label stays before the button resets.

number2000
cooldown

Ignore new attempts for this long after one settles, in ms.

number—
isRetryable

Decides whether a failure keeps the same key for a safe retry.

(error: unknown) => boolean—

* required. Also accepts the native props of <button> (className, aria-*, data-*…), passed through to it.

Accessibility

  • Uses aria-busy instead of disabled while pending, so the button keeps focus
  • Pending, success and retry states have visible text labels; icons are aria-hidden
  • Errors render with role=alert and are linked via aria-describedby

Only what the source does. Test with your own screen reader and content before shipping.

Limitations

This is a UX guard rail. Enforce on the server: Store the idempotency key with the result and return the stored result for repeats; add a unique constraint so duplicate writes fail.

  • Does not guarantee a single charge or record; that requires the server to honour the idempotency key
  • The key lives in memory: a reload, remount or second tab gets a new key
  • Classifies retryable errors with a default rule; pass isRetryable for your API

What it stops

Helps prevent paying twice after a timeout

Also known as

submit button · idempotency key · double submit · pay now · place order · loading button · debounce · razorpay

Field type

Dependencies

Pairs well with