Skip to main content

VORA Mirror — Using React

@vonpay/vora-react is a thin React wrapper around @vonpay/vora-js that handles provider lifecycle, session retrieval, and field registration via React-idiomatic primitives.

If you're not on React, use @vonpay/vora-js directly. The React package adds no new capability — only ergonomics.


Install

npm install @vonpay/vora-js @vonpay/vora-react

@vonpay/vora-js is a peer dependency. The React package re-exports FrameError, isFrameError, and the VORAConfig / VORASession / VORAFieldStyle / TokenizeOptions / TokenizeResult types so you don't have to import both.


<VORAProvider> — Owns the VORA client

Wrap your checkout subtree with <VORAProvider>. It constructs the underlying VORA instance, retrieves the session, and exposes context to useVORA() consumers.

import { VORAProvider, CardField, useVORA } from "@vonpay/vora-react";

function App() {
const [sessionId, setSessionId] = React.useState<string | null>(null);

React.useEffect(() => {
fetch("/api/create-session", { method: "POST" })
.then((r) => r.json())
.then(({ session_id }) => setSessionId(session_id));
}, []);

if (!sessionId) return <p>Loading checkout…</p>;

return (
<VORAProvider
publishableKey={import.meta.env.VITE_VONPAY_PUBLISHABLE_KEY}
sessionId={sessionId}
>
<Checkout />
</VORAProvider>
);
}

Props

interface VORAProviderProps {
publishableKey: string; // required — vp_pk_test_* or vp_pk_live_*
sessionId?: string; // optional at first render; provider waits
apiVersion?: string; // optional — pin the API version
apiBaseUrl?: string; // optional — defaults to https://checkout.vonpay.com
locale?: string; // BCP-47, en-only today
onError?: (err: FrameError) => void; // surfaces session-retrieve / adapter-load failures
children: React.ReactNode;
}

Lifecycle

  1. On first render, <VORAProvider> constructs a VORA instance from publishableKey (validates the key shape; asserts secure context).
  2. When sessionId is non-null, the provider calls vora.sessions.retrieve(sessionId), which lazy-loads the binder adapter chunk.
  3. On adapter-load success, ready flips to true and useVORA() consumers can call tokenize() etc.
  4. The VORA instance is stable for the provider's lifetime. Re-mounting the provider creates a fresh instance.

Don't toggle publishableKey after first render. The provider doesn't replace the underlying VORA instance when the prop changes; the merchant pattern is "one provider per checkout page lifetime." If you need to swap keys, unmount + remount the provider.


<CardField> — Card iframe (single-element legacy path)

Mounts the binder-served card iframe. Internally calls the legacy vora.fields.create('card', ...) single-element path that pre-dates the multi-element collection API. Use this when card is the only VORA element on the page; for multi-element checkouts (card + email + address + …) use the imperative API below until React wrappers for the new elements ship in a future release.

import { CardField } from "@vonpay/vora-react";

function Checkout() {
return (
<form onSubmit={handleSubmit}>
<CardField
style={{
font: { size: "16px", family: "system-ui" },
color: { primary: "#1a1a1a", placeholder: "#9ca3af" },
}}
placeholder={{ number: "4242 4242 4242 4242" }}
onReady={() => console.log("ready")}
onChange={(e) => setComplete(e.complete)}
/>
<button type="submit" disabled={!complete}>Pay</button>
</form>
);
}

Props

interface CardFieldProps {
style?: VORAFieldStyle;
placeholder?: { number?: string; expiry?: string; cvc?: string };
onReady?: () => void;
onChange?: (event: { complete: boolean; empty: boolean; error?: { message: string } }) => void;
onFocus?: () => void;
onBlur?: () => void;
}

React wrappers for the new elements (<AddressField>, <EmailField>, <CardholderField>, <SaveForFutureUseField>) ship in a follow-up release. Until then, mount the new elements via the imperative API below — that's what samples/frame-react does today.


Imperative API for multi-element checkouts

The multi-element collection API is not yet wrapped in React components. Mount it imperatively from a useEffect:

import * as React from "react";
import { VORA, isFrameError } from "@vonpay/vora-js";
import type { ElementsCollection, SubmitResult } from "@vonpay/vora-js";

function Checkout({ sessionId }: { sessionId: string }) {
const [status, setStatus] = React.useState<"loading" | "ready" | "submitting" | "submitted" | "error">("loading");
const [result, setResult] = React.useState<SubmitResult | null>(null);

// Mount targets
const cardRef = React.useRef<HTMLDivElement>(null);
const emailRef = React.useRef<HTMLDivElement>(null);
const cardholderRef = React.useRef<HTMLDivElement>(null);
const addressRef = React.useRef<HTMLDivElement>(null);
const saveRef = React.useRef<HTMLDivElement>(null);

// Hold the collection across renders so submit() can call into the same instance
const collectionRef = React.useRef<ElementsCollection | null>(null);

React.useEffect(() => {
setStatus("loading");
let cancelled = false;
let collection: ElementsCollection | null = null;

(async () => {
try {
const vora = new VORA({ publishableKey: import.meta.env.VITE_VONPAY_PUBLISHABLE_KEY });
await vora.sessions.retrieve(sessionId);
if (cancelled) return;

collection = vora.elements.create();
collectionRef.current = collection;

const card = collection.create("card", {
style: { color: { primary: "#1f2937" }, font: { family: "system-ui", size: "16px" } },
});
const email = collection.create("email", { placeholder: "you@example.com" });
const cardholder = collection.create("cardholder", { placeholder: "Name on card" });
const address = collection.create("address", { mode: "billing" });
const save = collection.create("save-for-future-use", {});

card.mount(cardRef.current!);
email.mount(emailRef.current!);
cardholder.mount(cardholderRef.current!);
address.mount(addressRef.current!);
save.mount(saveRef.current!);

if (!cancelled) setStatus("ready");
} catch (err) {
if (!cancelled) setStatus("error");
}
})();

return () => {
cancelled = true;
collection?.unmount();
collectionRef.current = null;
};
}, [sessionId]);

const onPay = React.useCallback(async () => {
const collection = collectionRef.current;
if (collection === null) return;
setStatus("submitting");
try {
const result = await collection.submit();
if (result.error !== undefined) {
setStatus("error");
return;
}
setResult(result);
setStatus("submitted");
// result.token === "vp_pmt_*"; pass to your server for /v1/payment_intents
await chargeServer(result.token);
} catch (err) {
setStatus("error");
}
}, []);

return (
<form onSubmit={(e) => { e.preventDefault(); onPay(); }}>
<label>Card<div ref={cardRef} /></label>
<label>Email<div ref={emailRef} /></label>
<label>Name on card<div ref={cardholderRef} /></label>
<label>Billing address<div ref={addressRef} /></label>
<div ref={saveRef} />
<button type="submit" disabled={status !== "ready"}>Pay</button>
</form>
);
}

SubmitResult shape

interface SubmitResult {
token: string; // vp_pmt_*
last4: string;
brand: string;
email?: string; // if email element mounted
cardholder?: { name: string }; // if cardholder element mounted
billingAddress?: BillingAddressValue; // if address element mounted
setupForFutureUse?: boolean; // if save-for-future-use element mounted
error?: FrameError; // present iff submit failed pre-tokenize
}

result.error covers the pre-tokenize failure mode (validation: required field empty, malformed input). The token field is present iff error is undefined. Network failures / unhandled binder rejections still throw — wrap submit() in try/catch to handle both paths.

Lifecycle gotchas

  • Effect cleanup matters. The return from useEffect calls collection.unmount() to tear down all elements. Without it, re-mounting the form (e.g., when sessionId changes) leaks DOM nodes + binder iframes.
  • Toggling options re-mounts. If your effect deps include element options like disable3dsModal, flipping the option re-runs setup. Synchronously set status: "loading" at the top of the effect to gate the submit button during the cleanup-then-setup window.
  • Differentiate setup-error vs submit-error. Errors during element mount (binder load failure) are non-retryable — the form needs a fresh mount. Errors during submit (validation, tokenization) are retryable — the user can fix and click Pay again. Keep this distinction in your error state so the Pay button stays disabled on setup errors and re-enables on submit errors.

The full reference for these patterns lives in samples/frame-react/src/App.tsx.


useVORA() — Hook surface

Returns the active session state plus the three async action methods.

interface UseVORAResult {
ready: boolean;
error: FrameError | null;
tokenize: (options?: TokenizeOptions) => Promise<TokenizeResult>;
confirmPaymentIntent: (intent: PaymentIntentRef) => Promise<ConfirmResult>;
handleAction: (action: unknown) => Promise<ConfirmResult>;
}

Pattern

function PayButton() {
const { tokenize, ready, error } = useVORA();
const [submitting, setSubmitting] = React.useState(false);

if (error) return <p>Checkout unavailable: {error.message}</p>;
if (!ready) return <p>Loading payment form…</p>;

return (
<button
type="submit"
disabled={submitting}
onClick={async (e) => {
e.preventDefault();
setSubmitting(true);
try {
const { token } = await tokenize();
await chargeServer(token);
} catch (err) {
alert(err.message);
} finally {
setSubmitting(false);
}
}}
>
Pay
</button>
);
}

Synchronous-throw loading-state contract

Calling tokenize(), confirmPaymentIntent(), or handleAction() before ready === true throws frame_session_not_ready synchronously — not as a rejected promise.

This is by design. Misuse (e.g. wiring a submit button before the field is mounted) fails loud at the call site, not asynchronously where it might leave the merchant's submit button hung.

The pattern that protects against this is "render the submit button only when ready === true":

if (!ready) return <Skeleton />;
return <button onClick={() => tokenize()}>Pay</button>;

3DS — confirmPaymentIntent + handleAction

For the inline 3DS flow (server-driven confirm), use confirmPaymentIntent:

const { confirmPaymentIntent } = useVORA();

async function handleSubmit() {
// 1. Tokenize
const { token } = await tokenize();

// 2. Server creates a manual-confirm payment intent
const intent = await fetch("/api/create-intent", {
method: "POST",
body: JSON.stringify({ payment_method: token }),
}).then((r) => r.json());
// intent: { id, status: "requires_action", clientSecret, … }

// 3. The SDK handles the 3DS challenge
const result = await confirmPaymentIntent(intent);
// result.status: "succeeded" | "requires_action" | "failed"
}

For the "next action" flow where your server returns a nextAction object directly, use handleAction:

const result = await handleAction(intent.nextAction);

Both run the binder's challenge UI inside VORA Mirror's harmonized 3DS modal (see disable3dsModal on the card element options to opt out of harmonization).


Error handling in React

error from useVORA() is the most-recent FrameError. The provider also accepts onError for callback-style handling:

<VORAProvider
publishableKey={}
sessionId={}
onError={(err) => {
if (err.code === "frame_session_expired") {
// Refresh session
} else {
Sentry.captureException(err);
}
}}
>

</VORAProvider>

See the errors page for the full code union and recovery semantics.


What's next