TL;DR / An idempotent UI action produces the same result regardless of how many times it is executed, preventing duplicate submissions, double charges, and inconsistent state from retries or accidental repeat clicks.
How It Works
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Click │ │ Request │ │ Result │
│ "Submit" │───────→│ #1 │───────→│ A │
└────────────┘ └────────────┘ └────────────┘
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Click │ │ Request │ │ Result │
│ again │───────→│ #2 │───────→│ A (same) │
└────────────┘ └────────────┘ └────────────┘
In distributed systems, exactly-once delivery is impossible. Networks drop packets, users double-click buttons, browser retries resubmit forms, and React's concurrent mode may execute event handlers in unexpected ways. Idempotency is the practical solution: design every action so that performing it multiple times has the same effect as performing it once.
The Problem Space
Consider a payment form. The user clicks "Pay," the request is sent, but the response is slow. The user clicks again. Without idempotency protection, two payment requests hit the server, and the user is charged twice. The same problem exists at every layer: the browser can retry on network errors, service workers can replay queued requests, and React's <form> action can be submitted multiple times.
The UI layer is the first line of defense because it is closest to the user, but it cannot be the only defense. Client-side protections (disabling buttons, blocking duplicate clicks) can be bypassed, and network retries happen below the application layer.
Client-Side Idempotency Patterns
Disable-on-submit: Disable the submit button after the first click and re-enable it when the response arrives. This prevents accidental double-clicks but does not protect against network retries, page refreshes, or back-button resubmissions.
Request deduplication: Track in-flight requests by a key (e.g., the form's action URL + payload hash). If a request with the same key is already in-flight, ignore the duplicate. Libraries like TanStack Query do this automatically for mutations.
Idempotency keys: Generate a unique key (UUID) when the form mounts and include it with every submission. The server uses this key to detect and reject duplicate requests. If the same key arrives twice, the server returns the original response without processing the action again. This is the gold standard for payment processing APIs (Stripe requires idempotency keys).
Optimistic state with deduplication: When an action fires, immediately update the UI optimistically and mark the action as pending. If the same action fires again while pending, ignore it or return the existing optimistic state. This combines instant feedback with duplicate prevention.
Server-Side Requirements
True idempotency requires server cooperation. The server must store processed idempotency keys (typically in a database or cache with TTL) and check incoming requests against them. For database operations, this often means using INSERT ... ON CONFLICT DO NOTHING or UPSERT semantics rather than blind INSERT.
PUT and DELETE are naturally idempotent by HTTP spec -- PUT /users/123 {name: "Alice"} always sets the name to Alice regardless of repetition, and DELETE /users/123 has no additional effect after the first deletion. POST is not idempotent by default, which is why POST-based mutations need explicit idempotency handling.
React-Specific Concerns
React 18's useTransition and Server Actions in frameworks like Next.js can invoke actions concurrently. If a user submits a form action while a previous submission is still in-flight, both requests reach the server. React's form action model tracks the pending state of submissions, but the server must still handle duplicates because the client cannot guarantee exactly-once delivery.
useOptimistic (React 19) creates a local state overlay that reflects the expected result of an in-flight action. If the user performs the action again, the optimistic state already shows the expected result, making the duplicate feel like a no-op. But the actual request still fires unless you explicitly deduplicate it.
Beyond Clicks: Idempotent State Updates
State reducers should also be idempotent. Dispatching {type: 'ADD_ITEM', id: 5} twice should not add item 5 twice if the intent is to ensure item 5 exists. Use set-based semantics (check for existence before adding) rather than append-based semantics. This applies to Redux reducers, useReducer, and any event-sourced state.
The distinction between "toggle" and "set" is important. TOGGLE_FAVORITE is not idempotent -- calling it twice reverses the action. SET_FAVORITE(true) is idempotent -- calling it twice is the same as calling it once. Prefer explicit state-setting actions over toggles in any context where duplication is possible.
Gotchas
- Disabling a button does not prevent network-level retries. The browser or a service worker may retry the request automatically on timeout. Server-side idempotency keys are essential.
Math.random()for idempotency keys is weak -- collisions are possible and the key is not reproducible. Usecrypto.randomUUID()and generate the key when the form mounts, not when it submits.- Toggle actions are inherently non-idempotent.
TOGGLE_DARK_MODEflipped twice returns to the original state. Replace withSET_DARK_MODE(true)for idempotent behavior. - React StrictMode double-invokes effects and render functions in development. If an effect sets up a subscription that triggers an action, the double-mount cycle can expose non-idempotent actions that appear to work in production.
- Browser back button resubmits forms (POST-redirect-GET pattern exists specifically to prevent this). Without idempotency keys, users pressing back can trigger duplicate server-side mutations.