A small framework-agnostic helper for preventing duplicate submissions with automatic idempotency key generation client-side and server-side idempotency.
You can install it directly from GitHub:
npm install https://github.com/madebyankur/use-safe-submit.gitOr add it to your package.json:
{
"dependencies": {
"use-safe-submit": "github:madebyankur/use-safe-submit"
}
}- Prevents double-click and accidental resubmits
- Automatic idempotency key generation (crypto.randomUUID())
- Works on Edge (Vercel Functions) and Node
- Framework-agnostic, works with any React setup
- Accessible defaults (disabled state, ARIA, focus return)
- Optional retry logic for specific status codes
The hook automatically generates a UUID idempotency key and injects it into the request:
- If your submission uses
FormData, it appends a hidden fieldidempotency-keyto the form data - If your submission uses
fetchwith JSON (or any body), the hook temporarily wrapsfetchduring the submission to set theIdempotency-Keyheader
import { useSafeSubmit } from "use-safe-submit";
export default function SubscribeForm() {
const { handleSubmit, isSubmitting, error } = useSafeSubmit(
async (formData: FormData) => {
// You don't need to add the key manually.
// The hook already appended `idempotency-key` to this FormData
const response = await fetch("/api/subscribe", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Subscription failed");
}
},
{
retryableStatusCodes: [502, 503],
onError: (err) => console.error("Submission error:", err),
}
);
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input name="email" type="email" required aria-invalid={!!error} />
<div role="alert" aria-live="polite">
{error && <span>Error: {String(error)}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Subscribe"}
</button>
</form>
);
}The server wrapper automatically extracts the key from either:
Idempotency-Keyrequest header, oridempotency-keyin form data, oridempotency-keyin JSON body
You do NOT need to read the idempotency key in your handler.
// app/subscribe/route.ts
import { NextResponse } from "next/server";
import { withIdempotency, MemoryStore } from "use-safe-submit/server";
const store = new MemoryStore(); // Use RedisStore in production
async function subscribeHandler(req: Request) {
// No need to read idempotency key here; it's validated by the wrapper
const formData = await req.formData();
const email = formData.get("email");
return NextResponse.json({ success: true });
}
export const POST = withIdempotency(subscribeHandler, { store });// pages/api/subscribe.ts
import { withIdempotency, MemoryStore } from "use-safe-submit/server";
const store = new MemoryStore(); // Use RedisStore in production
async function subscribeHandler(req: Request) {
const formData = await req.formData();
const email = formData.get("email");
return new Response(JSON.stringify({ success: true }), {
headers: { "Content-Type": "application/json" },
});
}
export default withIdempotency(subscribeHandler, { store });import express from "express";
import { withIdempotency, MemoryStore } from "use-safe-submit/server";
const app = express();
const store = new MemoryStore();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const subscribeHandler = async (req: Request) => {
const { email } = req.body;
return new Response(JSON.stringify({ success: true }), {
headers: { "Content-Type": "application/json" },
});
};
app.post("/api/subscribe", withIdempotency(subscribeHandler, { store }));const { handleSubmit, isSubmitting, error, idempotencyKey, reset } =
useSafeSubmit(submitFn, options);submitFn: (formData: FormData) => Promise<void>- Your submission functionoptions?: SafeSubmitOptions- Configuration options
handleSubmit: (e: FormEvent) => Promise<void>- Form submission handlerisSubmitting: boolean- Loading stateerror: unknown- Error stateidempotencyKey: string- Generated idempotency keyreset: () => void- Reset function
interface SafeSubmitOptions {
retryableStatusCodes?: number[]; // Status codes to retry on
disabledClassName?: string; // CSS class for disabled state
onError?: (error: unknown) => void; // Error callback
onSuccess?: () => void; // Success callback
}const wrappedHandler = withIdempotency(handler, options);handler: (req: Request, ...args) => Promise<Response>- Your API handleroptions?: IdempotencyOptions- Configuration options
interface IdempotencyOptions {
store?: IdempotencyStore; // Storage backend (default: MemoryStore)
timeToLiveMs?: number; // Time-to-live in milliseconds (default: 24h)
keyExtractor?: (req: Request) => string | null; // Custom key extractor
}import { RedisStore } from "use-safe-submit";
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
const store = new RedisStore(redis);- Default time-to-live: 24 hours. Prevents replay within a day; tune per use case.
- Cross-tab safe: server-side enforcement ensures duplicate submits from multiple tabs reuse the key and are rejected.
import { MemoryStore } from "use-safe-submit";
const store = new MemoryStore();- While submitting, the hook disables the submit button automatically
- On error, focus returns to the first
[aria-invalid="true"]field or the submit button - Announce errors using
role="alert"andaria-liveregions (see example above)
function ContactForm() {
const { handleSubmit, isSubmitting } = useSafeSubmit(async (formData) => {
await fetch("/api/contact", { method: "POST", body: formData });
});
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send"}
</button>
</form>
);
}"use client";
import { useSafeSubmit } from "use-safe-submit";
export default function SubscribeForm() {
const { handleSubmit, isSubmitting, error } = useSafeSubmit(
async (formData: FormData) => {
const response = await fetch("/api/subscribe", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Subscription failed");
}
}
);
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Subscribing..." : "Subscribe"}
</button>
</form>
);
}import { useSafeSubmit } from "use-safe-submit";
export default function ContactForm() {
const { handleSubmit, isSubmitting } = useSafeSubmit(async (formData) => {
await fetch("/api/contact", {
method: "POST",
body: formData,
});
});
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send"}
</button>
</form>
);
}import { useSafeSubmit } from "use-safe-submit";
function LoginForm() {
const { handleSubmit, isSubmitting, error } = useSafeSubmit(
async (formData: FormData) => {
const response = await fetch("/api/login", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Login failed");
}
}
);
return (
<form onSubmit={handleSubmit}>
<input name="username" required />
<input name="password" type="password" required />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Logging in..." : "Login"}
</button>
{error && <div>Error: {String(error)}</div>}
</form>
);
}- Double-click submit → 1 server call
- Refresh & resubmit same body within TTL → 409
- Two tabs, same intent → 409 for second
- Retry on 502/503 → retries then success
- Works on Vercel Edge, Node.js, and other runtimes
- Uses
globalThis.crypto.subtlefor SHA-256 hashing - No Node.js-specific APIs
- Upstash Redis recommended for storage
Run the test suite:
npm testRun tests in watch mode:
npm run test:watchBuild the library:
npm run build