Easy Labs
SDKsReactExamples

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:

  1. Lets the user choose card or bank account.
  2. Renders the corresponding PCI-isolated elements.
  3. Submits to useEasy().createPaymentInstrument, which tokenizes the elements and saves a Finix-backed instrument against customerId in a single round trip.
  4. 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

src/components/PaymentForm.tsx
"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 cardElement ref is a RefObject<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 on type keeps 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

  • createPaymentInstrument vs. checkout. createPaymentInstrument saves a card without charging it — useful for "add a payment method" pages. If you want to save and charge in one step, call checkout with the same element refs in source (and customer_creation: true for new customers). The example app's CheckoutForm.tsx does 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 createPaymentInstrument so you never see the raw token. If you need to mint a bare token (e.g. to stash for later), call tokenizePaymentInstrument directly. You give up the typed instrument record in exchange.
  • Customer must exist first. createPaymentInstrument requires customerId. Create the customer with createCustomer (or use checkout({ customer_creation: true }) to do both at once).

On this page