Hooks de Stripe para React
Receta
Usa useStripe y useElements de @stripe/react-stripe-js para acceder a instancias de Stripe en tus componentes. Crea hooks personalizados para encapsular patrones de pago comunes y reducir el boilerplate.
Hooks integrados:
"use client";
import { useStripe, useElements } from "@stripe/react-stripe-js";
function PaymentForm() {
// Accede a la instancia de Stripe.js
const stripe = useStripe();
// Accede a la instancia de Elements (gestiona los Elements montados)
const elements = useElements();
// Ambos devuelven null hasta que Stripe.js carga
if (!stripe || !elements) return <div>Cargando...</div>;
// Ahora puedes usar stripe.confirmPayment, elements.submit(), etc.
}Hook personalizado para el estado del pago:
// 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 };
}Hook personalizado para el flujo completo de checkout:
// 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 ?? "La validación falló");
return;
}
const { error: confirmError, paymentIntent } =
await stripe.confirmPayment({
elements,
confirmParams: { return_url: returnUrl },
redirect: "if_required",
});
if (confirmError) {
setFailed(confirmError.message ?? "El pago falló");
} 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,
};
}Ejemplo funcional
// 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">
¡Pago exitoso!
</h2>
<p className="text-gray-600 mt-2">
Gracias por tu compra.
</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
? "Procesando..."
: `Pagar $${(amount / 100).toFixed(2)}`}
</button>
</form>
);
}Hook personalizado para el estado de suscripción:
// 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("Error al obtener el estado de la suscripción");
} 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 };
}Uso en un panel:
// 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>Cargando...</div>;
return (
<div>
{isPastDue && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
<p className="text-yellow-800">
Tu pago está vencido. Actualiza tu método de pago.
</p>
</div>
)}
{isActive ? (
<h1>¡Bienvenido de nuevo! Estás en el plan {subscription?.plan}.</h1>
) : (
<h1>Mejora tu plan para acceder a funciones premium.</h1>
)}
</div>
);
}Análisis profundo
Cómo funciona
useStripe()devuelve la instancia deStripedel proveedorElementsmás cercano. Devuelvenullhasta que Stripe.js termina de cargar de forma asíncrona.useElements()devuelve la instancia deElementsque gestiona todos los Stripe Elements montados (PaymentElement, CardElement, etc.). También devuelvenullhasta que esté listo.- Ambos hooks deben llamarse dentro de un componente envuelto por
<Elements>. Llamarlos fuera lanza un error. - Hooks personalizados como
useCheckoutcomponen los hooks integrados con gestión de state para crear patrones de pago reutilizables. Esto mantiene los componentes de formulario enfocados en la presentación. - La opción
redirect: "if_required"enconfirmPaymentte permite manejar el resultado en el cliente para pagos con tarjeta, a la vez que sigue admitiendo redirecciones de 3D Secure cuando hace falta.
Variaciones
Hook con reintento automático:
// 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: "No está listo" };
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: "Se superó el máximo de reintentos" };
},
[stripe, elements, maxRetries]
);
return { confirmWithRetry, retryCount };
}Notas de TypeScript
useStripe()devuelveStripe | null(de@stripe/stripe-js).useElements()devuelveStripeElements | null.- Al crear hooks personalizados, tipa el valor de retorno de forma explícita para mejorar el soporte del IDE.
import type { Stripe, StripeElements } from "@stripe/stripe-js";
// Los hooks integrados devuelven tipos anulables
const stripe: Stripe | null = useStripe();
const elements: StripeElements | null = useElements();Trampas
useStripeyuseElementsdevuelvennulldurante la carga inicial. Comprueba siempre que no sean null antes de usarlos en manejadores de eventos.- Estos hooks deben usarse dentro de un componente descendiente de
<Elements>. Usarlos fuera lanzará un error en tiempo de ejecución. - No desestructures
stripenielementsy los pases a closures que se ejecuten más tarde. La referencia puede quedar obsoleta. Accede siempre a ellos dentro del callback. - Los hooks personalizados que envuelven hooks de Stripe también deben usarse dentro de
<Elements>. Este requisito se propaga hacia arriba a través de la composición. - El callback
onSuccessen hooks personalizados debe envolverse enuseCallbackpor el consumidor para evitar recrear el callback interno del hook en cada renderizado. - Stripe.js carga de forma asíncrona desde un CDN. En condiciones de red deficientes, puede tardar varios segundos. Muestra siempre un estado de carga.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Hooks integrados (useStripe, useElements) | API sencilla y oficial | Verboso en cada componente |
| Hooks personalizados de envoltura | Reutilizables, encapsulan la lógica | Abstracción adicional que mantener |
| Patrón render-prop | Funciona con componentes de clase | Verboso, patrón obsoleto |
| Stripe.js directo (sin React) | Sin dependencia de React | Gestión manual del DOM |
Preguntas frecuentes
¿Qué devuelven useStripe y useElements antes de que Stripe.js termine de cargar?
Ambos devuelven null. Debes comprobar siempre que no sean null antes de usarlos en manejadores de eventos o lógica de UI. Muestra un estado de carga mientras sean null.
¿Por qué useStripe y useElements deben llamarse dentro de un proveedor Elements?
Leen las instancias de Stripe y Elements del Context de React proporcionado por <Elements>. Llamarlos fuera lanza un error en tiempo de ejecución. Este requisito se propaga a cualquier hook personalizado que los envuelva.
¿Qué encapsula el hook personalizado usePaymentStatus?
- Rastrea el estado del pago como
"idle" | "processing" | "succeeded" | "failed" - Gestiona una cadena de mensaje de error
- Proporciona callbacks memorizados (
setProcessing,setSucceeded,setFailed,reset) para actualizar el state - Mantiene los componentes de formulario enfocados en la presentación al extraer la lógica de state
¿Cómo simplifica el hook personalizado useCheckout los formularios de pago?
Combina useStripe, useElements y usePaymentStatus en un solo hook que devuelve handlePayment, status, error, isReady e isProcessing. Un componente de formulario puede llamar a handlePayment() al enviar sin gestionar la lógica de Stripe directamente.
¿Qué hace redirect: "if_required" en el hook useCheckout?
Indica a confirmPayment que solo redirija para métodos de pago que lo requieran (como 3D Secure). Para pagos simples con tarjeta, el resultado se maneja en el cliente, lo que te permite mostrar un mensaje de éxito sin redirección de página.
Trampa: ¿Por qué debes evitar desestructurar stripe o elements y pasarlos a closures?
Las referencias pueden quedar obsoletas cuando el closure se ejecute. Accede siempre a stripe y elements dentro del cuerpo del callback, o asegúrate de que el closure capture la referencia más reciente mediante un useCallback con las dependencias correctas.
¿Por qué el callback onSuccess en useCheckout debe envolverse en useCallback por el consumidor?
Sin useCallback, la función onSuccess se recrea en cada renderizado, lo que provoca que el callback interno handlePayment de useCheckout también se recree en cada renderizado (ya que onSuccess está en su array de dependencias).
¿Cómo funciona el hook useSubscriptionStatus?
const { subscription, loading, isActive, isPastDue, isCanceled } =
useSubscriptionStatus();Obtiene datos de suscripción de /api/subscription/status al montar y deriva flags booleanos (isActive, isPastDue, isCanceled) del campo status.
Trampa: ¿Qué ocurre si Stripe.js carga lentamente por una red deficiente?
useStripe() y useElements() devuelven null hasta que el script del CDN carga. En condiciones de red deficientes esto puede tardar varios segundos. Muestra siempre un estado de carga significativo, no una pantalla en blanco.
TypeScript: ¿Qué tipos devuelven useStripe y useElements?
import type { Stripe, StripeElements } from "@stripe/stripe-js";
const stripe: Stripe | null = useStripe();
const elements: StripeElements | null = useElements();Ambos son anulables hasta que Stripe.js se inicializa.
¿Cómo implementa el hook usePaymentRetry la lógica de reintento?
- Itera hasta
maxRetriesintentos ante fallos de tipoapi_error - Cada reintento espera con un retraso creciente (
1000 * (attempt + 1)ms) - Los errores no reintentables (como
card_error) se devuelven de inmediato - Rastrea el conteo de reintentos para mostrarlo en la UI