State management
Manage checkout state carefully to avoid re-initialization and stale data. Keep configuration objects stable and observe state changes with lifecycle-aware collectors.- Web
- Android
- iOS
Define options outside functions
Create options objects once and reuse them to avoid unnecessary re-initialization:Define static options outside your function scope. The SDK uses deep comparison to detect actual changes, but stable object references reduce comparison overhead and improve performance.
// ✅ GOOD: Created once
const SDK_OPTIONS = {
locale: 'en-GB',
};
function initCheckout() {
const checkout = document.querySelector('primer-checkout');
checkout.options = SDK_OPTIONS; // Same reference every time
}
// ❌ AVOID: Created every time function runs
function initCheckout() {
const checkout = document.querySelector('primer-checkout');
checkout.options = { locale: 'en-GB' }; // New object every execution
}
The SDK performs deep comparison to detect actual changes in the
options object. Using stable references (the GOOD pattern above) minimizes comparison overhead and remains the recommended best practice for optimal performance.Use lifecycle-aware state collection
Always usecollectAsStateWithLifecycle() instead of collectAsState() to prevent unnecessary work when the app is in the background:val state by checkout.state.collectAsStateWithLifecycle()
Avoid wrapping the controller in a ViewModel
The checkout controller is already a ViewModel internally. Don’t wrap it in another ViewModel unless you need to coordinate with other app state.Define settings and theme as constants
CreatePrimerSettings and PrimerCheckoutTheme outside your view body to avoid unnecessary re-creation on each SwiftUI render cycle:SwiftUI re-evaluates
body frequently. Defining configuration objects as constants ensures they are created once and reused.// ✅ GOOD: Created once
private let primerSettings = PrimerSettings(paymentHandling: .auto)
private let primerTheme = PrimerCheckoutTheme(
colors: ColorOverrides(primerColorBrand: .blue)
)
struct CheckoutView: View {
let clientToken: String
var body: some View {
PrimerCheckout(
clientToken: clientToken,
primerSettings: primerSettings,
primerTheme: primerTheme
)
}
}
// ❌ AVOID: Created every render
struct CheckoutView: View {
let clientToken: String
var body: some View {
PrimerCheckout(
clientToken: clientToken,
primerSettings: PrimerSettings(paymentHandling: .auto),
primerTheme: PrimerCheckoutTheme(colors: ColorOverrides(primerColorBrand: .blue))
)
}
}
Use proper @State management
When storing checkout-related values in SwiftUI state, use@State for simple values and @StateObject for observable objects:struct CheckoutView: View {
let clientToken: String
@State private var paymentCompleted = false
@State private var paymentResult: PaymentResult?
var body: some View {
if paymentCompleted, let result = paymentResult {
ConfirmationView(result: result)
} else {
PrimerCheckout(
clientToken: clientToken,
onCompletion: { state in
if case .success(let result) = state {
paymentResult = result
paymentCompleted = true
}
}
)
}
}
}
Observe the lifecycle through the session
For composable, inline checkout, hold aPrimerCheckoutSession as a @StateObject. Observe its @Published properties for lifecycle and per-feature state, and receive outcomes through onCompletion. The session is automatically started on appear and torn down on disappear by the .primerCheckoutSession(_:theme:onCompletion:) modifier — you don’t manage an AsyncStream or a manual Task:struct CheckoutView: View {
@StateObject private var session: PrimerCheckoutSession
init(clientToken: String) {
_session = StateObject(
wrappedValue: PrimerCheckoutSession(clientToken: clientToken)
)
}
var body: some View {
ScrollView {
PrimerCardForm()
}
.primerCheckoutSession(session) { state in
// .failure once per failed attempt, then .success or .dismissed exactly once
handleStateChange(state)
}
}
}
onCompletion. A .failure after the checkout is ready is delivered once per failed attempt, such as a declined payment or a failed refresh(). The checkout stays usable so the shopper can retry, so don’t tear it down on .failure. .success and .dismissed are delivered exactly once, and nothing is delivered after them. A .failure while the checkout is still initializing ends the session and is delivered once. The lifecycle comes from the session’s @Published phase and clientSession. The per-feature sessions (cardForm, selection) become available once phase is .ready, and their @Published state drives your custom slots.Error handling
Log diagnostics IDs and handle failures gracefully. Every error from the SDK includes identifiers that help Primer support diagnose issues.- Web
- Android
- iOS
Monitor SDK initialization
checkout.addEventListener('primer:ready', (event) => {
const primer = event.detail;
console.log('SDK initialized');
console.log('Applied locale:', checkout.options?.locale);
console.log('Active payment methods:', primer.getPaymentMethods?.());
});
Debug configuration issues
Common debugging approaches for options-related issues:When options aren’t working as expected, check these common issues first:
const options = { locale: 'en-GB' };
console.log('Options reference:', options);
// Later in code
checkout.options = options;
console.log('Applied options:', checkout.options);
console.log('References match:', checkout.options === options);
// Component properties use setAttribute()
const checkout = document.querySelector('primer-checkout');
checkout.setAttribute('client-token', 'new-token');
console.log('Token attribute:', checkout.getAttribute('client-token'));
// SDK options use direct property assignment
checkout.options = { locale: 'en-GB' };
console.log('Options object:', checkout.options);
Always handle outcomes
The Android Components SDK always usesAUTO mode — there is no manual mode. Even with default screens, observe checkout.state for logging and analytics:val state by checkout.state.collectAsStateWithLifecycle()
LaunchedEffect(state) {
when (val s = state) {
is PrimerCheckoutState.Success -> {
analytics.trackPurchase(s.checkoutData)
}
is PrimerCheckoutState.Failure -> {
analytics.trackError(s.primerError.diagnosticsId)
}
else -> Unit
}
}
PrimerCheckoutSheet(checkout = checkout)
Log diagnostics IDs
EveryPrimerError includes a diagnosticsId. Log it for troubleshooting with Primer support:is PrimerCheckoutState.Failure -> {
Log.e("Checkout", "Error: ${s.primerError.diagnosticsId}")
}
Handle all completion states
Handle every outcome in theonCompletion callback. .failure arrives once per failed attempt, including a failed initialization. While uiOptions.isErrorScreenEnabled is true (the default), the checkout stays open so the shopper can retry, so don’t close the checkout on .failure. .success or .dismissed arrives exactly once and ends the checkout. onCompletion never receives .initializing or .ready. The switch lists them only to stay exhaustive:PrimerCheckout(
clientToken: clientToken,
onCompletion: { state in
switch state {
case .success(let result):
handleSuccess(result)
case let .failure(error, _):
handleFailure(error)
case .dismissed:
handleDismissal()
case .initializing, .ready:
break // not delivered to onCompletion
}
}
)
.dismissed arrives when the shopper closes the checkout from the SDK’s own screens. If you present PrimerCheckout in your own .sheet, a swipe-down closes your sheet without calling onCompletion. Also reset your presentation state in .sheet(isPresented:onDismiss:).Log diagnostics IDs
Every error includes adiagnosticsId. Log it, together with recoverySuggestion, for troubleshooting with Primer support. recoverySuggestion is developer-facing English text, so show the shopper your own message:case let .failure(error, _):
print("Error diagnosticsId: \(error.diagnosticsId)")
if let suggestion = error.recoverySuggestion {
print("Recovery suggestion: \(suggestion)")
}
Performance
Minimize unnecessary re-renders and keep configuration stable. Initialize the SDK as early as possible so it can prefetch configuration while the user navigates.- Web
- Android
- iOS
Use TypeScript interfaces for type safety
Define TypeScript interfaces for your options objects to catch errors at compile time:interface PrimerSDKOptions {
locale: string;
paymentMethodOptions?: {
PAYMENT_CARD?: {
requireCVV?: boolean;
requireBillingAddress?: boolean;
};
APPLE_PAY?: {
merchantName?: string;
merchantCountryCode?: string;
};
};
}
const options: PrimerSDKOptions = {
locale: 'en-GB',
paymentMethodOptions: {
PAYMENT_CARD: {
requireCVV: true,
requireBillingAddress: true,
},
},
};
Test options configuration separately
Create isolated tests for your options configuration:describe('SDK Options Configuration', () => {
it('should create valid options object', () => {
const options = {
locale: 'en-GB',
};
expect(options.locale).toBe('en-GB');
});
it('should maintain stable reference', () => {
const options = { locale: 'en-GB' };
const checkout = document.querySelector('primer-checkout');
checkout.options = options;
expect(checkout.options).toBe(options); // Same reference
});
});
Initialize early
Create the checkout controller as early as possible so the SDK can fetch configuration while the user navigates to checkout:@Composable
fun CheckoutScreen(clientToken: String) {
val checkout = rememberPrimerCheckoutController(clientToken)
// SDK starts loading immediately
}
Avoid recreating the controller
rememberPrimerCheckoutController survives recomposition. Don’t call it conditionally or inside callbacks.Avoid creating objects in SwiftUI body
Define PrimerSettings and PrimerCheckoutTheme as constants outside the view body. SwiftUI re-evaluates body frequently, so inline allocations waste resources:// ✅ GOOD: Defined once outside body
private let primerSettings = PrimerSettings(paymentHandling: .auto)
struct CheckoutView: View {
let clientToken: String
var body: some View {
PrimerCheckout(
clientToken: clientToken,
primerSettings: primerSettings
)
}
}
Let the session own its lifecycle
For composable checkout, hold thePrimerCheckoutSession as a @StateObject and attach the .primerCheckoutSession(_:theme:onCompletion:) modifier. The modifier starts the session on appear and tears it down on disappear, so you never manage a manual Task or AsyncStream:struct CheckoutView: View {
@StateObject private var session: PrimerCheckoutSession
init(clientToken: String) {
_session = StateObject(
wrappedValue: PrimerCheckoutSession(clientToken: clientToken)
)
}
var body: some View {
ScrollView {
PrimerCardForm()
}
.primerCheckoutSession(session) { state in
handleStateChange(state)
}
}
}
Don’t retain self in onCompletion
The completion closure can outlive the view that created it. When forwarding the outcome to a long-lived object such as a view model, capture it weakly to avoid retain cycles:.primerCheckoutSession(session) { [weak viewModel] state in
Task { await viewModel?.handleState(state) }
}
Security
Never expose API keys in client code. Keep configuration minimal and handle sensitive data on your server.- Web
- Android
- iOS
Distinguish between component properties and SDK options
Component Properties are HTML attributes that configure the component container. SDK Options are configuration settings for the SDK itself.Mixing these up will cause silent failures. Component properties must use
setAttribute(). SDK options must be assigned directly to .options.setAttribute()):client-token- API authenticationcustom-styles- Visual themingloader-disabled- Loader behavior
options object):locale- UI language- Payment method configuration
- Feature settings
- Merchant domain and API settings
// ✅ CORRECT: Clear separation
const checkout = document.querySelector('primer-checkout');
// Component properties
checkout.setAttribute('client-token', 'your-token');
checkout.setAttribute('loader-disabled', 'false');
// SDK options
checkout.options = {
locale: 'en-GB',
applePay: { buttonType: 'buy' },
};
// ❌ WRONG: Mixing them up
checkout.setAttribute('locale', 'en-GB'); // Won't work - locale is an SDK option
checkout.options = {
clientToken: 'your-token', // Wrong - this is a component property
};
Keep options simple and focused
Only configure what you need:// ✅ GOOD: Simple, focused configuration
checkout.options = {
locale: 'en-GB',
};
// ❌ AVOID: Over-configuration with unused options
checkout.options = {
locale: 'en-GB',
paymentMethodOptions: {
PAYMENT_CARD: {
requireCVV: true,
requireBillingAddress: false,
// ... 20 more unused options
},
APPLE_PAY: {
// ... configured but not used
},
},
};
Client token handling
- Generate client tokens on your server, never hardcode them
- Client tokens are single-use and expire — generate a fresh one for each checkout session
- Never log or persist client tokens
ProGuard
The SDK includes its own ProGuard rules. Don’t add additional rules that might strip required classes.Client token handling
- Generate client tokens on your server, never hardcode them
- Client tokens are single-use and expire — generate a fresh one for each checkout session
- Never log or persist client tokens
Keep configuration minimal
Only configure what you need inPrimerSettings. The SDK ships with sensible defaults:// ✅ GOOD: Only override what you need
private let primerSettings = PrimerSettings(paymentHandling: .auto)
// ❌ AVOID: Over-configuration with defaults
private let primerSettings = PrimerSettings(
paymentHandling: .auto,
localeData: nil,
uiOptions: nil
)
Testing
Use sandbox mode with test cards to verify your integration before going to production.- Web
- Android
- iOS
Test your checkout in a sandbox environment. Verify that:
- Options are applied correctly
- Events fire as expected
- Error handling works for declined cards
- All payment methods render properly
Use sandbox environment
Always test with sandbox client tokens before going to production. Use test card numbers:| Card | Result |
|---|---|
4111 1111 1111 1111 | Success |
4000 0000 0000 0002 | Declined |
Test dark mode
Verify your custom theme looks correct in both light and dark modes:// Force dark mode for testing
val theme = PrimerTheme(
darkColorTokens = object : DarkColorTokens() {
override val primerColorBrand: Color = Color(0xFFA29BFE)
},
)
Accessibility
The SDK supports TalkBack and other accessibility services. Default components include:- Content descriptions for all interactive elements
- Semantic roles (buttons, inputs, headings)
- Focus management for screen transitions
Modifier.semantics {
contentDescription = "Pay with Visa ending in 4242"
role = Role.Button
}
Use sandbox environment
Always test with sandbox client tokens before going to production. Use test card numbers:| Card | Result |
|---|---|
4111 1111 1111 1111 | Success |
4000 0000 0000 0002 | Declined |
Test dark mode
Verify your custom theme looks correct in both light and dark modes using the iOS simulator’s appearance settings or by overriding the color scheme in your preview:#Preview {
CheckoutView(clientToken: "sandbox-token")
.preferredColorScheme(.dark)
}
Accessibility
The SDK supports VoiceOver and other iOS accessibility services. Default components include:- Accessibility labels for all interactive elements
- Trait annotations (button, header, text field)
- Focus management for screen transitions
Text("Pay with Visa ending in 4242")
.accessibilityLabel("Pay with Visa ending in 4242")
.accessibilityAddTraits(.isButton)