useRef Lock for Payment API — Prevent Duplicate Payments in React

java dev.to

One of the most common bugs in payment flows: user clicks the button twice, and two payments get processed.
Stopping it with useState? Unreliable. Stopping it with useRef? Clean, instant, and re-render-free.

Why not useState?

// ❌ Problem with useState
const [isLoading, setIsLoading] = useState(false);

const handlePayment = async () => {
  if (isLoading) return; // async state update — might not catch double-click in time
  setIsLoading(true);
  await processPayment();
  setIsLoading(false);
};

Enter fullscreen mode Exit fullscreen mode

useState updates are asynchronous. A fast double-click can pass the check before the state actually updates.

The Fix — useRef Lock

import { useRef } from 'react';

const paymentLock = useRef(false);

const handlePayment = async () => {
  if (paymentLock.current) return; // instant, synchronous check

  paymentLock.current = true; // lock immediately

  try {
    await processPayment();
  } finally {
    paymentLock.current = false; // always unlock, even on error
  }
};
Enter fullscreen mode Exit fullscreen mode

The finally block is critical. If the payment fails, the lock still releases — otherwise your button stays permanently disabled.

Real-World Example

import { useRef, useState } from 'react';

export default function PaymentButton({ amount, onSuccess }) {
  const paymentLock = useRef(false);
  const [status, setStatus] = useState('idle'); // only for UI feedback

  const handlePayment = async () => {
    if (paymentLock.current) return;

    paymentLock.current = true;
    setStatus('processing');

    try {
      await fetch('/api/payment', {
        method: 'POST',
        body: JSON.stringify({ amount }),
      });
      setStatus('success');
      onSuccess();
    } catch (err) {
      setStatus('error');
    } finally {
      paymentLock.current = false;
    }
  };

  return (
    <button onClick={handlePayment} disabled={status === 'processing'}>
      {status === 'processing' ? 'Processing...' : `Pay $${amount}`}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Simple rule: useRef handles the logic lock, useState handles the UI update. Both do their own job.

useRef vs Other Approaches

Approach Problem
useStateAsync update — fast double-click can slip

throughDebounce / Throttle Fine for search inputs, not reliable for financial transactions

Backend-only Still required, but frontend lock reduces unnecessary API calls

Best Practice Checklist for Payment Flows

✅ Frontend Lock — useRef to block duplicate requests
✅ Disable Button — disabled attribute during processing
✅ Idempotency Keys — Handle duplicates safely on the backend
✅ Transaction Validation — Verify every transaction server-side

Frontend lock improves UX. Backend lock protects data. You need both.

useRef doesn't trigger re-renders — that's exactly why it works as a payment lock. The value updates instantly with zero async delay.

Small change on the frontend. Big impact on reliability. 🚀

Source: dev.to

arrow_back Back to Tutorials