Meet Modi
Back to Blog
·8 min

Three ways for a payment to finish, and my component only handled one

A payment flow has three exits: the user finishes it, the user closes the window, or the component that started it is already gone. Mine only handled the first one.

By Meet Modi
PaymentsReactState Management

Confirming a course payment meant three steps: create an order on the backend, hand off to a third-party checkout window, then poll an endpoint until the backend confirmed the payment actually cleared. The gateway doesn't always confirm synchronously, so polling was load-bearing, not decorative.

In testing, this worked every time. In production, a chunk of users landed on a spinner that never resolved, and a smaller chunk got charged an error toast for a payment that had actually succeeded.

The bug: my polling loop assumed the component asking the question would still be around to hear the answer. It usually was.

Take 1: a boolean and a setInterval

function PaymentButton({ courseId }: { courseId: string }) {
  const [loading, setLoading] = useState(false);

  async function handlePay() {
    setLoading(true);
    const order = await createOrder(courseId);
    openCheckoutWindow(order.id);

    const interval = setInterval(async () => {
      const status = await checkPaymentStatus(order.id);
      if (status === 'confirmed') {
        clearInterval(interval);
        setLoading(false);
        router.push('/course/' + courseId);
      } else if (status === 'failed') {
        clearInterval(interval);
        setLoading(false);
        showError('Payment failed');
      }
    }, 2000);
  }

  return <button onClick={handlePay} disabled={loading}>Pay</button>;
}

Two clicks on Pay before the first order finishes creating spins up two intervals, each polling the same status endpoint, each ready to call setState on the same component. Whichever one resolves second wins, even if it's answering a stale question.

The other failure mode: the user navigates away, or the modal hosting this button unmounts, while an interval is still ticking. The poll fires anyway, gets a response, and calls setState on a component that no longer exists.

Take 2: an explicit state machine with ref guards

I replaced the boolean with named states, because "loading" was doing the work of five different situations. Then I added refs for the things that needed to survive across renders and unmounts without triggering re-renders themselves.

type PaymentState = 'idle' | 'creatingOrder' | 'awaitingUser' | 'verifying' | 'resolved';
const MAX_POLL_ATTEMPTS = 15;

function usePaymentFlow(courseId: string) {
  const [state, setState] = useState<PaymentState>('idle');
  const mountedRef = useRef(true);
  const verifyingRef = useRef(false);
  const attemptRef = useRef(0);

  useEffect(() => {
    mountedRef.current = true;
    return () => { mountedRef.current = false; };
  }, []);

  async function pay() {
    setState('creatingOrder');
    const order = await createOrder(courseId);
    if (!mountedRef.current) return;

    setState('awaitingUser');
    openCheckoutWindow(order.id);
    verify(order.id);
  }

  async function verify(orderId: string) {
    if (verifyingRef.current) return;
    verifyingRef.current = true;
    attemptRef.current = 0;
    setState('verifying');

    while (attemptRef.current < MAX_POLL_ATTEMPTS) {
      if (!mountedRef.current) return;
      const status = await checkPaymentStatus(orderId);
      if (!mountedRef.current) return;

      if (status === 'confirmed' || status === 'failed') {
        verifyingRef.current = false;
        setState('resolved');
        return status;
      }
      attemptRef.current += 1;
      await sleep(2000);
    }

    verifyingRef.current = false;
    setState('resolved');
    return 'unknown';
  }

  return { state, pay };
}

The mounted-ref check runs before every setState call, so a response arriving after unmount just gets dropped on the floor instead of crashing or corrupting state. The verifying-ref stops a second call to pay from starting a second overlapping poll, since the flag is already true from the first one. Every setState is gated on both.

The attempt counter turned an infinite loop into a bounded one. After 15 tries at two seconds apart, the flow gives up and tells the user to check their order status manually instead of spinning forever on a gateway that's gone quiet.

What I should have done first

I should have written down the three ways this flow actually ends before writing the happy path: the user finishes, the user bails, the component is gone by the time the answer arrives. Take 1 only handled the first one because that's the one I tested by clicking the button myself and waiting. Nobody manually tests closing a modal mid-poll unless they're specifically looking for that bug, and I wasn't, until production found it for me.

More Posts