Easy Labs
SDKsReact NativeExamples

Payment Form

Payment Form pattern for React Native.

A reusable native checkout screen that collects customer details, captures card data inside Basis Theory's PCI-scoped elements, and runs useEasy().checkout() to create a customer, tokenize the card, and place an order in one call.

Goal

Ship a card payment screen that:

  • Captures the PAN, expiration, and CVC inside native inputs (no card data ever in your component state).
  • Creates a new customer and order in a single round-trip via checkout().
  • Surfaces validation, network, and tokenization errors back to the user.
  • Works the same way on iOS and Android with no platform-specific branching.

The same pattern adapts to bank account (ACH) capture by swapping the element types and source.type.

Implementation

src/screens/PaymentFormScreen.tsx
import {
  type BTDateRef,
  type BTRef,
  CardExpirationDateElement,
  CardNumberElement,
  CardVerificationCodeElement,
  useEasy,
} from '@easylabs/react-native';
import { useRef, useState } from 'react';
import {
  ActivityIndicator,
  Alert,
  ScrollView,
  StyleSheet,
  Text,
  TextInput,
  TouchableOpacity,
  View,
} from 'react-native';

const PRICE_ID = 'price_…'; // from your Easy dashboard

export default function PaymentFormScreen() {
  const { checkout } = useEasy();

  const cardNumberRef = useRef<BTRef>(null);
  const cardExpirationRef = useRef<BTDateRef>(null);
  const cardCvcRef = useRef<BTRef>(null);

  const [form, setForm] = useState({ firstName: '', lastName: '', email: '' });
  const [loading, setLoading] = useState(false);

  const handleSubmit = async () => {
    if (!form.firstName || !form.lastName || !form.email) {
      Alert.alert('Missing info', 'First name, last name, and email are required.');
      return;
    }

    setLoading(true);
    try {
      const result = await checkout({
        customer_creation: true,
        customer_details: {
          first_name: form.firstName,
          last_name: form.lastName,
          email: form.email,
        },
        source: {
          type: 'PAYMENT_CARD',
          name: `${form.firstName} ${form.lastName}`,
          cardNumberElement: cardNumberRef,
          cardExpirationDateElement: cardExpirationRef,
          cardVerificationCodeElement: cardCvcRef,
        },
        line_items: [{ price_id: PRICE_ID, quantity: 1 }],
        metadata: { source: 'react-native-app' },
      });

      if (result.success) {
        Alert.alert('Paid', `Order ${result.data.orderId}`);
      }
    } catch (err) {
      Alert.alert('Error', err instanceof Error ? err.message : 'Payment failed');
    } finally {
      setLoading(false);
    }
  };

  return (
    <ScrollView contentContainerStyle={styles.container}>
      <Text style={styles.label}>First name</Text>
      <TextInput
        style={styles.input}
        value={form.firstName}
        onChangeText={(v) => setForm({ ...form, firstName: v })}
        editable={!loading}
      />

      <Text style={styles.label}>Last name</Text>
      <TextInput
        style={styles.input}
        value={form.lastName}
        onChangeText={(v) => setForm({ ...form, lastName: v })}
        editable={!loading}
      />

      <Text style={styles.label}>Email</Text>
      <TextInput
        style={styles.input}
        value={form.email}
        onChangeText={(v) => setForm({ ...form, email: v })}
        keyboardType="email-address"
        autoCapitalize="none"
        editable={!loading}
      />

      <Text style={styles.label}>Card number</Text>
      <View style={styles.element}>
        <CardNumberElement btRef={cardNumberRef} />
      </View>

      <View style={styles.row}>
        <View style={styles.flex}>
          <Text style={styles.label}>Expiration</Text>
          <View style={styles.element}>
            <CardExpirationDateElement btRef={cardExpirationRef} />
          </View>
        </View>
        <View style={styles.flex}>
          <Text style={styles.label}>CVC</Text>
          <View style={styles.element}>
            <CardVerificationCodeElement btRef={cardCvcRef} />
          </View>
        </View>
      </View>

      <TouchableOpacity
        style={[styles.button, loading && styles.buttonDisabled]}
        onPress={handleSubmit}
        disabled={loading}
      >
        {loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Pay</Text>}
      </TouchableOpacity>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { padding: 16, gap: 8 },
  label: { fontSize: 14, fontWeight: '500', marginTop: 12 },
  input: { borderWidth: 1, borderColor: '#ddd', borderRadius: 8, padding: 12, fontSize: 16 },
  element: { borderWidth: 1, borderColor: '#ddd', borderRadius: 8, padding: 12, minHeight: 50 },
  row: { flexDirection: 'row', gap: 12 },
  flex: { flex: 1 },
  button: {
    backgroundColor: '#007AFF',
    borderRadius: 8,
    padding: 16,
    alignItems: 'center',
    marginTop: 24,
  },
  buttonDisabled: { backgroundColor: '#ccc' },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

Variations

Existing customer. Skip customer_creation and pass an identity_id:

await checkout({
  customer_creation: false,
  identity_id: 'cust_…',
  source: {
    type: 'PAYMENT_CARD',
    name: 'Jane Doe',
    cardNumberElement: cardNumberRef,
    cardExpirationDateElement: cardExpirationRef,
    cardVerificationCodeElement: cardCvcRef,
  },
  line_items: [{ price_id: PRICE_ID, quantity: 1 }],
});

Stored payment instrument. Pass the instrument id as source directly — no element refs needed:

await checkout({
  customer_creation: false,
  identity_id: 'cust_…',
  source: 'pi_…',
  line_items: [{ price_id: PRICE_ID, quantity: 1 }],
});

Bank account (ACH). Replace card refs with two TextElement refs and switch source.type:

import { TextElement, type BTRef } from '@easylabs/react-native';

const routingRef = useRef<BTRef>(null);
const accountRef = useRef<BTRef>(null);

await checkout({
  customer_creation: true,
  customer_details: { first_name: 'Jane', last_name: 'Doe' },
  source: {
    type: 'BANK_ACCOUNT',
    accountType: 'CHECKING',
    name: 'Jane Doe',
    routingElement: routingRef,
    accountElement: accountRef,
  },
  line_items: [{ price_id: PRICE_ID, quantity: 1 }],
});

// Inputs:
<TextElement btRef={routingRef} keyboardType="numeric" placeholder="Routing number" />
<TextElement btRef={accountRef} keyboardType="numeric" placeholder="Account number" secureTextEntry />

Tradeoffs

  • Element refs must be mounted at submit time. Don't conditionally hide a card element behind a tab and call checkout() from a different tab — the ref will be null and tokenization throws "Card element reference is not set". If you need multi-step UI, mount the element off-screen or guard the submit on a known-good screen.
  • No on-device validation gating before submit. The element exposes onChange events with complete / valid flags (see Elements → Validation) — wire a disabled state into the submit button if you want to block submission until the form is complete locally.
  • checkout() is a single transaction. Customer creation, tokenization, instrument creation, and the order all share one promise. If you need to retry only the order portion (e.g. after a 3DS retry), call createPaymentInstrument first to get a stable instrument ID, then call checkout() with that ID as the source.
  • Don't use this for Apple Pay or Google Pay. Wallet flows have their own native sheet — see Wallet Checkout. Only fall back to manual card entry when the wallet is unavailable.

On this page