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);
};
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
}
};
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>
);
}
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. 🚀