Stripe Elements with React: Seamless Checkout

You reach this page the moment a hosted payment element has to live inside a single-page React app: the iframe must mount once, survive re-renders, route a 3D Secure challenge without a full-page navigation, and never let an impatient double-click create two charges. This is the framework-specific layer beneath the broader Payment Element Integration cluster β€” the architecture there (idempotent intents, webhook-as-truth) is assumed; here we focus on the React wiring that makes it hold under real user behavior.

The recurring mistakes are React-specific: re-instantiating loadStripe on every render, passing a fresh options object that remounts the element and discards user input, and treating the confirmPayment resolve as success when it is only a hint. Each has a precise fix below.

Trade-offs

The first decision is where the PaymentIntent is created and how much the React layer trusts its own result. The table contrasts the common approaches with concrete values.

Approach First-paint latency Double-charge risk 3DS handling SPA routing preserved Best for
Create intent on mount, confirm client-side +1 round trip (~150 ms) before element renders Low with idempotency key redirect: 'if_required', inline Yes Most SaaS checkouts
Create intent lazily on submit 0 ms at mount, ~200 ms on submit Medium β€” submit must lock first Inline, but later Yes High-bounce funnels
Redirect to PSP hosted page Near-zero integration Very low (PSP owns it) PSP-hosted, full redirect No (hard navigation) Lowest PCI scope, low custom UX
Confirm + trust client result as final β€” High β€” webhook ignored Fragile across redirect Sometimes Never in production

The third column is the one teams underestimate: without a lock and an idempotency key, a 200 ms confirm window plus a double-click is enough to bill twice.

Step-by-Step Implementation

1. Isolate the Stripe instance and memoize options

Call loadStripe once at module scope, and memoize the options object so a parent re-render does not remount the element and wipe the user’s input.

import { Elements } from '@stripe/react-stripe-js';
import { loadStripe, StripeElementsOptions } from '@stripe/stripe-js';
import { useMemo, ReactNode } from 'react';

// βœ… module scope β€” runs once, not per render
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

export function CheckoutProvider({ clientSecret, children }: { clientSecret: string; children: ReactNode }) {
  const options = useMemo<StripeElementsOptions>(
    () => ({ clientSecret, appearance: { theme: 'flat' }, loader: 'auto' }),
    [clientSecret], // ⚠️ unstable deps here remount the element and drop card input
  );
  return <Elements stripe={stripePromise} options={options}>{children}</Elements>;
}

2. Fetch the client secret from an idempotent endpoint

The endpoint keys the intent on the checkout attempt_id, so a retried fetch returns the same PaymentIntent rather than creating a second one (see Payment Element Integration for the server code).

async function fetchClientSecret(attemptId: string): Promise<string> {
  const res = await fetch('/api/checkout/intent', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ attempt_id: attemptId }), // server derives idempotency_key from this
  });
  if (!res.ok) throw new Error('intent_creation_failed');
  return (await res.json()).clientSecret;
}

3. Confirm with a state machine and a submit lock

Disable the form on first click (isSubmitting) and use redirect: 'if_required' so a 3DS step-up renders inline instead of navigating away.

import { useStripe, useElements } from '@stripe/react-stripe-js';
import { useState, useCallback } from 'react';

type PaymentState = 'idle' | 'submitting' | 'requires_action' | 'optimistic' | 'failed';

export function usePayment() {
  const stripe = useStripe();
  const elements = useElements();
  const [state, setState] = useState<PaymentState>('idle');
  const [error, setError] = useState<string | null>(null);

  const submit = useCallback(async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements || state === 'submitting') return; // βœ… lock blocks double-submit
    setState('submitting'); setError(null);

    const { error: err, paymentIntent } = await stripe.confirmPayment({
      elements,
      confirmParams: { return_url: `${window.location.origin}/checkout/return` },
      redirect: 'if_required',
    });

    if (err) { setError(err.message ?? 'Payment failed'); setState('failed'); }      // βœ— decline
    else if (paymentIntent?.status === 'requires_action') setState('requires_action'); // ⚠️ 3DS
    else setState('optimistic'); // not final β€” webhook confirms
  }, [stripe, elements, state]);

  return { state, error, submit };
}

4. Reconcile the UI against the webhook

optimistic is not succeeded. Poll your own status endpoint (which is updated by the webhook handler) with backoff until the subscription reports active, then show success.

import { useEffect, useState, useRef } from 'react';

export function useReconcile(subscriptionId: string, active: boolean) {
  const [confirmed, setConfirmed] = useState(false);
  const tries = useRef(0);
  useEffect(() => {
    if (!active) return;
    const t = setInterval(async () => {
      tries.current += 1;
      const { status } = await fetch(`/api/subscription/${subscriptionId}/status`).then((r) => r.json());
      if (status === 'active') { setConfirmed(true); clearInterval(t); }
      if (tries.current >= 6) clearInterval(t); // ~30s; surface "still processing" UI
    }, 5000);
    return () => clearInterval(t);
  }, [subscriptionId, active]);
  return confirmed;
}

Verification & Testing

Render the component under React Testing Library with @stripe/react-stripe-js stubbed and assert elements.getElement('payment') returns an instance before submit fires. Assert the submit handler early-returns when state === 'submitting' β€” that is the double-click guard, and it should be a unit test, not a manual check. In an integration test, fire the same attempt_id against /api/checkout/intent twice and assert one PaymentIntent id comes back both times. Use Stripe test card 4000002500003155 to force requires_action and assert the UI renders the inline challenge rather than navigating. Finally, replay the payment_intent.succeeded webhook and assert /api/subscription/:id/status flips to active exactly once.

Gotchas & Production Pitfalls

  • loadStripe inside the component. Re-running it per render re-downloads the SDK and can leave two Stripe instances fighting over the iframe. Hoist it to module scope.
  • Unstable options object. Passing a new { clientSecret, appearance } literal each render remounts Elements and silently clears the card field mid-typing. Memoize on clientSecret only.
  • Treating confirmPayment resolve as success. It resolves optimistic, not settled. If you grant access here, a later payment_failed webhook leaves you with an active sub that never paid. Gate access on the webhook.
  • Ad blockers stripping Stripe.js. Detect a missing window.Stripe and render a non-blocking banner; Stripe ships no fallback CDN, so user instruction is the only mitigation.
  • CSP blocking the iframe. *.stripe.com and *.stripe.network must be allowed in both script-src and frame-src, or the element mounts to nothing in staging while passing locally.