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
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 benulland 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
onChangeevents withcomplete/validflags (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), callcreatePaymentInstrumentfirst to get a stable instrument ID, then callcheckout()with that ID as thesource.- 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.