Easy Labs
SDKsReact NativeExamples

Customer Management

Customer Management pattern for React Native.

A native screen for listing, creating, and editing customers — the data plane that complements the Payment Form recipe. No card data is involved here, so this pattern is independent of Basis Theory and runs as soon as EasyProvider mounts.

Goal

Build a screen that:

  • Loads existing customers with getCustomers.
  • Creates new customers with createCustomer.
  • Edits an existing customer with updateCustomer.
  • Uses a single form for both create and edit, toggled by selection state.

This is the same shape as the ProfileScreen in the Expo example — adapted here as a standalone recipe.

Implementation

src/screens/CustomersScreen.tsx
import type { CustomerData } from '@easylabs/react-native';
import { useEasy } from '@easylabs/react-native';
import { useCallback, useEffect, useState } from 'react';
import {
  ActivityIndicator,
  Alert,
  ScrollView,
  StyleSheet,
  Text,
  TextInput,
  TouchableOpacity,
  View,
} from 'react-native';

export default function CustomersScreen() {
  const { getCustomers, createCustomer, updateCustomer } = useEasy();

  const [customers, setCustomers] = useState<CustomerData[]>([]);
  const [selected, setSelected] = useState<CustomerData | null>(null);
  const [editing, setEditing] = useState(false);
  const [loading, setLoading] = useState(false);

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

  const fillFormFrom = useCallback((c: CustomerData) => {
    setForm({
      firstName: c.entity.first_name,
      lastName: c.entity.last_name,
      email: c.entity.email ?? '',
      phone: c.entity.phone ?? '',
    });
  }, []);

  const load = useCallback(async () => {
    setLoading(true);
    try {
      const result = await getCustomers({ limit: 20 });
      if (result.success) {
        setCustomers(result.data);
        if (result.data.length > 0) {
          setSelected(result.data[0]);
          fillFormFrom(result.data[0]);
        }
      }
    } catch (err) {
      Alert.alert('Error', err instanceof Error ? err.message : 'Failed to load');
    } finally {
      setLoading(false);
    }
  }, [getCustomers, fillFormFrom]);

  useEffect(() => {
    load();
  }, [load]);

  const handleCreate = async () => {
    if (!form.firstName || !form.lastName) {
      Alert.alert('Missing info', 'First and last name are required.');
      return;
    }
    setLoading(true);
    try {
      const result = await createCustomer({
        first_name: form.firstName,
        last_name: form.lastName,
        email: form.email,
        phone: form.phone,
      });
      if (result.success) {
        Alert.alert('Created', `Customer ${result.data.id}`);
        await load();
        setForm({ firstName: '', lastName: '', email: '', phone: '' });
      }
    } catch (err) {
      Alert.alert('Error', err instanceof Error ? err.message : 'Failed to create');
    } finally {
      setLoading(false);
    }
  };

  const handleUpdate = async () => {
    if (!selected) return;
    setLoading(true);
    try {
      const result = await updateCustomer(selected.id, {
        first_name: form.firstName,
        last_name: form.lastName,
        email: form.email,
        phone: form.phone,
      });
      if (result.success) {
        Alert.alert('Saved', 'Customer updated');
        await load();
        setEditing(false);
      }
    } catch (err) {
      Alert.alert('Error', err instanceof Error ? err.message : 'Failed to save');
    } finally {
      setLoading(false);
    }
  };

  return (
    <ScrollView contentContainerStyle={styles.container}>
      {customers.length > 0 && (
        <View>
          <Text style={styles.sectionTitle}>Customers</Text>
          <ScrollView horizontal showsHorizontalScrollIndicator={false}>
            {customers.map((c) => (
              <TouchableOpacity
                key={c.id}
                style={[styles.chip, selected?.id === c.id && styles.chipSelected]}
                onPress={() => {
                  setSelected(c);
                  fillFormFrom(c);
                  setEditing(false);
                }}
              >
                <Text style={[styles.chipText, selected?.id === c.id && styles.chipTextSelected]}>
                  {c.entity.first_name} {c.entity.last_name}
                </Text>
              </TouchableOpacity>
            ))}
          </ScrollView>
        </View>
      )}

      <Text style={styles.sectionTitle}>
        {selected && !editing ? 'Customer details' : selected ? 'Edit customer' : 'New customer'}
      </Text>

      <Text style={styles.label}>First name</Text>
      <TextInput
        style={styles.input}
        value={form.firstName}
        onChangeText={(v) => setForm({ ...form, firstName: v })}
        editable={!loading && (!selected || editing)}
      />

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

      <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 && (!selected || editing)}
      />

      <Text style={styles.label}>Phone</Text>
      <TextInput
        style={styles.input}
        value={form.phone}
        onChangeText={(v) => setForm({ ...form, phone: v })}
        keyboardType="phone-pad"
        editable={!loading && (!selected || editing)}
      />

      {selected && !editing ? (
        <TouchableOpacity style={styles.button} onPress={() => setEditing(true)}>
          <Text style={styles.buttonText}>Edit</Text>
        </TouchableOpacity>
      ) : selected && editing ? (
        <TouchableOpacity style={styles.button} onPress={handleUpdate} disabled={loading}>
          {loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Save</Text>}
        </TouchableOpacity>
      ) : (
        <TouchableOpacity style={styles.button} onPress={handleCreate} disabled={loading}>
          {loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.buttonText}>Create</Text>}
        </TouchableOpacity>
      )}
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { padding: 16, gap: 8 },
  sectionTitle: { fontSize: 18, fontWeight: '600', marginTop: 16, marginBottom: 8 },
  chip: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, backgroundColor: '#f0f0f0', marginRight: 8 },
  chipSelected: { backgroundColor: '#007AFF' },
  chipText: { color: '#333' },
  chipTextSelected: { color: '#fff' },
  label: { fontSize: 14, fontWeight: '500', marginTop: 12 },
  input: { borderWidth: 1, borderColor: '#ddd', borderRadius: 8, padding: 12, fontSize: 16 },
  button: { backgroundColor: '#007AFF', borderRadius: 8, padding: 16, alignItems: 'center', marginTop: 16 },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

useEasy() exposes the rest of the customer surface for follow-on screens:

const {
  // Single record
  getCustomer,
  // Subresources
  getCustomerPaymentInstruments,
  getCustomerOrders,
  getCustomerSubscriptions,
  getCustomerWallets,
} = useEasy();

const { data: instruments } = await getCustomerPaymentInstruments('cust_…');
const { data: orders } = await getCustomerOrders('cust_…', { limit: 20 });

Tradeoffs

  • All customer reads happen client-side under the publishable key. This is fine for a logged-in customer viewing their own profile or for admin tooling shipped behind your own auth, but it's not appropriate for surfacing other customers' data in a consumer app. For multi-tenant or operator-facing tooling, proxy customer reads through your server using the backend SDK.
  • updateCustomer is PATCH-style. Only fields you pass are updated; omitted fields keep their current value. To clear a field, pass an explicit null rather than omitting it.
  • No client-side pagination state. The example loads 20 records and stops. For large lists, hold an offset in component state and bump it on a "Load more" button — getCustomers({ limit, offset }) is paginated.
  • Tag schemas are unenforced. tags is Record<string, unknown> at the API level. Define your tag shape in your own type and cast at the call site (see TypeScript → Module augmentation).

On this page