Payment Form
Save a card or bank account against a customer in a single React form.
This recipe covers the most common Easy Labs React pattern: a checkout-style form that collects card or bank-account details with PCI-isolated elements and persists the result as a saved payment instrument against a customer you already have. It assumes you have an EasyProvider mounted somewhere above this form.
Goal
We want one form that:
- Lets the user choose card or bank account.
- Renders the corresponding PCI-isolated elements.
- Submits to
useEasy().createPaymentInstrument, which tokenizes the elements and saves a Finix-backed instrument againstcustomerIdin a single round trip. - Surfaces tokenization and API errors back to the user.
If you also want to charge the instrument in the same submission, swap createPaymentInstrument for checkout — see Tradeoffs.
Implementation
"use client";
import {
CardElement,
TextElement,
useEasy,
type AccountType,
type CreatePaymentInstrument,
type ICardElement,
type ITextElement,
} from "@easylabs/react";
import { useId, useRef, useState } from "react";
type Props = {
customerId: string;
onSaved?: (instrumentId: string) => void;
};
export function PaymentForm({ customerId, onSaved }: Props) {
const formId = useId();
const { createPaymentInstrument } = useEasy();
const [type, setType] = useState<CreatePaymentInstrument["type"]>("PAYMENT_CARD");
const [name, setName] = useState("");
const [accountType, setAccountType] = useState<AccountType>("PERSONAL_CHECKING");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const cardRef = useRef<ICardElement>(null);
const routingRef = useRef<ITextElement>(null);
const accountRef = useRef<ITextElement>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const res = await createPaymentInstrument(
type === "PAYMENT_CARD"
? {
type: "PAYMENT_CARD",
customerId,
name,
cardElement: cardRef,
}
: {
type: "BANK_ACCOUNT",
customerId,
name,
accountType,
routingElement: routingRef,
accountElement: accountRef,
},
);
if (res.success) {
onSaved?.(res.data.id);
} else {
setError(res.message ?? "Could not save payment method.");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Unexpected error.");
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit}>
<fieldset>
<legend>Payment method</legend>
<label>
<input
type="radio"
checked={type === "PAYMENT_CARD"}
onChange={() => setType("PAYMENT_CARD")}
/>
Card
</label>
<label>
<input
type="radio"
checked={type === "BANK_ACCOUNT"}
onChange={() => setType("BANK_ACCOUNT")}
/>
Bank account
</label>
</fieldset>
<label htmlFor={`${formId}-name`}>Name on payment method</label>
<input
id={`${formId}-name`}
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
{type === "PAYMENT_CARD" ? (
<CardElement ref={cardRef} id={`${formId}-card`} />
) : (
<>
<label htmlFor={`${formId}-routing`}>Routing number</label>
<TextElement ref={routingRef} id={`${formId}-routing`} />
<label htmlFor={`${formId}-account`}>Account number</label>
<TextElement ref={accountRef} id={`${formId}-account`} />
<label htmlFor={`${formId}-acct-type`}>Account type</label>
<select
id={`${formId}-acct-type`}
value={accountType}
onChange={(e) => setAccountType(e.target.value as AccountType)}
>
<option value="PERSONAL_CHECKING">Personal checking</option>
<option value="PERSONAL_SAVINGS">Personal savings</option>
<option value="BUSINESS_CHECKING">Business checking</option>
<option value="BUSINESS_SAVINGS">Business savings</option>
</select>
</>
)}
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={submitting}>
{submitting ? "Saving…" : "Save payment method"}
</button>
</form>
);
}Three things make this work:
- The
cardElementref is aRefObject<ICardElement>, not a DOM ref. The element is an iframe that exposes a typed API surface (.month(),.year(),.value). - Each branch passes the matching set of refs into
createPaymentInstrument. The SDK throws if the wrong combination is provided, so a strict discriminated union ontypekeeps it ergonomic. - The form key is generated with
useId()so multiple instances of<PaymentForm>on the same page don't collide on input IDs — important when you re-render after a save.
For a full version with TanStack Form, address fields, and validation, see examples/react/src/components/CreateInstrumentForm.tsx.
Tradeoffs
createPaymentInstrumentvs.checkout.createPaymentInstrumentsaves a card without charging it — useful for "add a payment method" pages. If you want to save and charge in one step, callcheckoutwith the same element refs insource(andcustomer_creation: truefor new customers). The example app'sCheckoutForm.tsxdoes this.- Split-card layout. Replace
<CardElement>with<CardNumberElement>+<CardExpirationDateElement>+<CardVerificationCodeElement>and pass all three refs. The SDK throws if you pass only some of them. - No raw token access. This recipe uses
createPaymentInstrumentso you never see the raw token. If you need to mint a bare token (e.g. to stash for later), calltokenizePaymentInstrumentdirectly. You give up the typed instrument record in exchange. - Customer must exist first.
createPaymentInstrumentrequirescustomerId. Create the customer withcreateCustomer(or usecheckout({ customer_creation: true })to do both at once).