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
loadStripeinside 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
optionsobject. Passing a new{ clientSecret, appearance }literal each render remountsElementsand silently clears the card field mid-typing. Memoize onclientSecretonly. - Treating
confirmPaymentresolve as success. It resolvesoptimistic, not settled. If you grant access here, a laterpayment_failedwebhook leaves you with an active sub that never paid. Gate access on the webhook. - Ad blockers stripping Stripe.js. Detect a missing
window.Stripeand render a non-blocking banner; Stripe ships no fallback CDN, so user instruction is the only mitigation. - CSP blocking the iframe.
*.stripe.comand*.stripe.networkmust be allowed in bothscript-srcandframe-src, or the element mounts to nothing in staging while passing locally.