Stripe React Hooks
Recipe
Use useStripe and useElements from @stripe/react-stripe-js to access Stripe instances in your components. Build custom hooks to encapsulate common payment patterns and reduce boilerplate.
Built-in hooks:
"use client";
import { useStripe, useElements } from "@stripe/react-stripe-js";
function PaymentForm() {
// Access the Stripe.js instance
const stripe = useStripe();
// Access the Elements instance (manages mounted Elements)
const elements = useElements();
// Both return null until Stripe.js loads
if (!stripe || !elements) return <div>Loading...</div>;
// Now you can use stripe.confirmPayment, elements.submit(), etc.
}Custom hook for payment status:
// hooks/use-payment-status.ts
"use client";
import { useState, useCallback } from "react";
type PaymentStatus = "idle" | "processing" | "succeeded" | "failed";
interface UsePaymentStatusReturn {
status: PaymentStatus;
error: string | null;
setProcessing: () => void;
setSucceeded: () => void;
setFailed: (message: string) => void;
reset: () => void;
}
export function usePaymentStatus(): UsePaymentStatusReturn {
const [status, setStatus] = useState<PaymentStatus>("idle");
const [error, setError] = useState<string | null>(null);
const setProcessing = useCallback(() => {
setStatus("processing");
setError(null);
}, []);
const setSucceeded = useCallback(() => {
setStatus("succeeded");
setError(null);
}, []);
const setFailed = useCallback((message: string) => {
setStatus("failed");
setError(message);
}, []);
const reset = useCallback(() => {
setStatus("idle");
setError(null);
}, []);
return { status, error, setProcessing, setSucceeded, setFailed, reset };
}Custom hook for the full checkout flow:
// hooks/use-checkout.ts
"use client";
import { useCallback } from "react";
import { useStripe, useElements } from "@stripe/react-stripe-js";
import { usePaymentStatus } from "./use-payment-status";
interface UseCheckoutOptions {
returnUrl: string;
onSuccess?: () => void;
}
export function useCheckout({ returnUrl, onSuccess }: UseCheckoutOptions) {
const stripe = useStripe();
const elements = useElements();
const { status, error, setProcessing, setSucceeded, setFailed, reset } =
usePaymentStatus();
const handlePayment = useCallback(async () => {
if (!stripe || !elements) return;
setProcessing();
const { error: submitError } = await elements.submit();
if (submitError) {
setFailed(submitError.message ?? "Validation failed");
return;
}
const { error: confirmError, paymentIntent } =
await stripe.confirmPayment({
elements,
confirmParams: { return_url: returnUrl },
redirect: "if_required",
});
if (confirmError) {
setFailed(confirmError.message ?? "Payment failed");
} else if (paymentIntent?.status === "succeeded") {
setSucceeded();
onSuccess?.();
}
}, [stripe, elements, returnUrl, onSuccess, setProcessing, setFailed, setSucceeded]);
return {
handlePayment,
status,
error,
isReady: !!stripe && !!elements,
isProcessing: status === "processing",
reset,
};
}Working Example
// app/checkout/checkout-form.tsx
"use client";
import { type FormEvent } from "react";
import { PaymentElement } from "@stripe/react-stripe-js";
import { useCheckout } from "@/hooks/use-checkout";
export function CheckoutForm({ amount }: { amount: number }) {
const {
handlePayment,
status,
error,
isReady,
isProcessing,
} = useCheckout({
returnUrl: `${window.location.origin}/success`,
onSuccess: () => {
console.log("Payment succeeded!");
},
});
function onSubmit(e: FormEvent) {
e.preventDefault();
handlePayment();
}
if (status === "succeeded") {
return (
<div className="text-center p-8">
<h2 className="text-2xl font-bold text-green-600">
Payment Successful!
</h2>
<p className="text-gray-600 mt-2">
Thank you for your purchase.
</p>
</div>
);
}
return (
<form onSubmit={onSubmit} className="max-w-md mx-auto p-6 space-y-6">
<PaymentElement />
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
<button
type="submit"
disabled={!isReady || isProcessing}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
>
{isProcessing
? "Processing..."
: `Pay $${(amount / 100).toFixed(2)}`}
</button>
</form>
);
}Custom hook for subscription status:
// hooks/use-subscription-status.ts
"use client";
import { useState, useEffect } from "react";
interface SubscriptionInfo {
plan: string;
status: string;
currentPeriodEnd: string | null;
}
export function useSubscriptionStatus() {
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(
null
);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchStatus() {
try {
const res = await fetch("/api/subscription/status");
if (res.ok) {
const data = await res.json();
setSubscription(data);
}
} catch {
console.error("Failed to fetch subscription status");
} finally {
setLoading(false);
}
}
fetchStatus();
}, []);
const isActive =
subscription?.status === "active" ||
subscription?.status === "trialing";
const isPastDue = subscription?.status === "past_due";
const isCanceled = subscription?.status === "canceled";
return { subscription, loading, isActive, isPastDue, isCanceled };
}Usage in a dashboard:
// app/dashboard/page.tsx
"use client";
import { useSubscriptionStatus } from "@/hooks/use-subscription-status";
export default function DashboardPage() {
const { subscription, loading, isActive, isPastDue } =
useSubscriptionStatus();
if (loading) return <div>Loading...</div>;
return (
<div>
{isPastDue && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
<p className="text-yellow-800">
Your payment is past due. Please update your payment method.
</p>
</div>
)}
{isActive ? (
<h1>Welcome back! You are on the {subscription?.plan} plan.</h1>
) : (
<h1>Upgrade to access premium features.</h1>
)}
</div>
);
}Deep Dive
How It Works
useStripe()returns theStripeinstance from the nearestElementsprovider. It returnsnulluntil Stripe.js finishes loading asynchronously.useElements()returns theElementsinstance that manages all mounted Stripe Elements (PaymentElement, CardElement, etc.). Also returnsnulluntil ready.- Both hooks must be called inside a component wrapped by
<Elements>. Calling them outside throws an error. - Custom hooks like
useCheckoutcompose the built-in hooks with state management to create reusable payment patterns. This keeps form components focused on presentation. - The
redirect: "if_required"option onconfirmPaymentallows you to handle the result client-side for card payments while still supporting 3D Secure redirects when needed.
Variations
Hook with automatic retry:
// hooks/use-payment-retry.ts
"use client";
import { useState, useCallback } from "react";
import { useStripe, useElements } from "@stripe/react-stripe-js";
export function usePaymentRetry(maxRetries = 2) {
const stripe = useStripe();
const elements = useElements();
const [retryCount, setRetryCount] = useState(0);
const confirmWithRetry = useCallback(
async (returnUrl: string) => {
if (!stripe || !elements) return { error: "Not ready" };
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: returnUrl },
redirect: "if_required",
});
if (!error) return { paymentIntent };
if (error.type !== "api_error" || attempt === maxRetries) {
return { error: error.message };
}
setRetryCount(attempt + 1);
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
}
return { error: "Max retries exceeded" };
},
[stripe, elements, maxRetries]
);
return { confirmWithRetry, retryCount };
}TypeScript Notes
useStripe()returnsStripe | null(from@stripe/stripe-js).useElements()returnsStripeElements | null.- When building custom hooks, type the return value explicitly for better IDE support.
import type { Stripe, StripeElements } from "@stripe/stripe-js";
// The built-in hooks return nullable types
const stripe: Stripe | null = useStripe();
const elements: StripeElements | null = useElements();Gotchas
useStripeanduseElementsreturnnullduring initial load. Always check for null before using them in event handlers.- These hooks must be used inside a component that is a descendant of
<Elements>. Using them outside will throw a runtime error. - Do not destructure
stripeorelementsand pass them to closures that run later. The reference may be stale. Always access them inside the callback. - Custom hooks that wrap Stripe hooks must also be used inside
<Elements>. This is a requirement that propagates up through composition. - The
onSuccesscallback in custom hooks should be wrapped inuseCallbackby the consumer to avoid recreating the hook's internal callback on every render. - Stripe.js loads asynchronously from a CDN. In poor network conditions, it can take several seconds. Always show a loading state.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Built-in hooks (useStripe, useElements) | Simple, official API | Verbose in every component |
| Custom wrapper hooks | Reusable, encapsulates logic | Extra abstraction to maintain |
| Render-prop pattern | Works with class components | Verbose, outdated pattern |
| Direct Stripe.js (no React) | No React dependency | Manual DOM management |
FAQs
What do useStripe and useElements return before Stripe.js finishes loading?
Both return null. You must always check for null before using them in event handlers or UI logic. Show a loading state while they are null.
Why do useStripe and useElements need to be called inside an Elements provider?
They read the Stripe and Elements instances from React Context provided by <Elements>. Calling them outside throws a runtime error. This requirement propagates to any custom hooks that wrap them.
What does the usePaymentStatus custom hook encapsulate?
- Tracks payment state as
"idle" | "processing" | "succeeded" | "failed" - Manages an error message string
- Provides memoized callbacks (
setProcessing,setSucceeded,setFailed,reset) to update state - Keeps form components focused on presentation by extracting state logic
How does the useCheckout custom hook simplify payment forms?
It combines useStripe, useElements, and usePaymentStatus into one hook that returns handlePayment, status, error, isReady, and isProcessing. A form component can call handlePayment() on submit without managing Stripe logic directly.
What does redirect: "if_required" do in the useCheckout hook?
It tells confirmPayment to only redirect for payment methods that require it (like 3D Secure). For simple card payments, the result is handled client-side, allowing you to show a success message without a page redirect.
Gotcha: Why should you avoid destructuring stripe or elements and passing them to closures?
The references may be stale by the time the closure executes. Always access stripe and elements inside the callback body, or ensure the closure captures the latest reference via a useCallback with the correct dependencies.
Why should the onSuccess callback in useCheckout be wrapped in useCallback by the consumer?
Without useCallback, the onSuccess function recreates on every render, causing useCheckout's internal handlePayment callback to also recreate on every render (since onSuccess is in its dependency array).
How does the useSubscriptionStatus hook work?
const { subscription, loading, isActive, isPastDue, isCanceled } =
useSubscriptionStatus();It fetches subscription data from /api/subscription/status on mount and derives boolean flags (isActive, isPastDue, isCanceled) from the status field.
Gotcha: What happens if Stripe.js loads slowly due to poor network?
useStripe() and useElements() return null until the CDN script loads. In poor network conditions this can take several seconds. Always show a meaningful loading state, not a blank screen.
TypeScript: What types do useStripe and useElements return?
import type { Stripe, StripeElements } from "@stripe/stripe-js";
const stripe: Stripe | null = useStripe();
const elements: StripeElements | null = useElements();Both are nullable until Stripe.js initializes.
How does the usePaymentRetry hook implement retry logic?
- It loops up to
maxRetriesattempts onapi_errorfailures - Each retry waits with increasing delay (
1000 * (attempt + 1)ms) - Non-retryable errors (like
card_error) return immediately - It tracks the retry count for UI display