Integrating Stripe Payment Element in React Without the Common Pitfalls
The Elements provider, the client secret lifecycle, and the re-render bug that breaks most first attempts.
Zeeshan
Founder · Published
The Elements provider, the client secret lifecycle, and the re-render bug that breaks most first attempts.
Zeeshan
Founder · Published
Payment Element gives you Stripe's full payment-method matrix inside your own React form. The integration is genuinely small: two packages, one provider, one component, but three details account for nearly every broken first attempt.
loadStripe returns a promise and kicks off a network request. Calling it inside a component body re-runs it on every render, which throws away the Elements instance and remounts the iframe. Hoist it to module scope.
import { loadStripe } from "@stripe/stripe-js";
import { Elements } from "@stripe/react-stripe-js";
// Module scope: evaluated once per page load, never per render.
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
export function CheckoutProvider({ clientSecret, children }) {
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
{children}
</Elements>
);
}The clientSecret in Elements options is not reactive in the way people expect. Changing it after mount does not cleanly re-initialize the Element. Fetch the PaymentIntent first, render a loading state meanwhile, and only mount Elements once the secret exists.
If you genuinely need to swap intents (a cart total that changes on the same screen), give the Elements provider a key prop tied to the client secret so React unmounts and remounts it as a fresh instance.
const stripe = useStripe();
const elements = useElements();
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!stripe || !elements) return; // Still loading, so the guard matters.
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/checkout/complete`,
},
});
// Only reached if confirmation failed before redirect.
if (error) setMessage(error.message ?? "Payment failed.");
}Note what that code does not do: treat the absence of an error as success. Some payment methods redirect the customer away and complete asynchronously. The return_url page should read the PaymentIntent status, and your actual fulfillment should hang off the payment_intent.succeeded webhook. Never off this function returning.
Stripe insights, monthly
One email a month, no spam, unsubscribe anytime.
No spam, unsubscribe anytime.
Let Stripe own the billing state and Django own the entitlement. Mixing the two is where it goes wrong.
The raw-body problem bites PHP integrations harder than most. Here's the correct shape.
Shopify Payments covers standard storefronts well: here's where it stops being enough.