React
This recipe wires @nossdev/iap into a React 18+ app using useSyncExternalStore — the modern idiom for subscribing to external state without dual-bookkeeping.
1. Create the IAP instance (singleton)
// src/services/iap.ts
import { createIAP } from '@nossdev/iap';
import type { EntitlementBase } from '@nossdev/iap';
export interface AppEntitlement extends EntitlementBase {
key: string;
productId: string;
expiresAt: string | null;
tier?: 'basic' | 'pro';
}
export const iap = createIAP<AppEntitlement>({
products: [
{ id: 'premium_monthly', type: 'subscription', androidPlanId: 'monthly-plan' },
{ id: 'remove_ads', type: 'product' },
],
backend: {
baseUrl: import.meta.env.VITE_API_URL,
endpoints: {
verifyApple: '/api/iap/verify/apple',
verifyGoogle: '/api/iap/verify/google',
entitlements: '/api/iap/entitlements',
restore: '/api/iap/restore',
},
getAuthHeaders: async () => ({
Authorization: `Bearer ${await getAuthToken()}`,
}),
},
});2. useEntitlements hook
// src/hooks/useEntitlements.ts
import { useSyncExternalStore } from 'react';
import { iap, type AppEntitlement } from '../services/iap';
export function useEntitlements(): AppEntitlement[] {
return useSyncExternalStore(
(onChange) => iap.on('entitlements-changed', onChange),
() => iap.getEntitlements(),
() => [], // SSR fallback
);
}
export function useHasEntitlement(key: string): boolean {
const entitlements = useEntitlements();
return entitlements.some((e) => e.key === key);
}
export function useEntitlement(key: string): AppEntitlement | null {
const entitlements = useEntitlements();
return entitlements.find((e) => e.key === key) ?? null;
}useSyncExternalStore handles concurrent rendering correctly — no torn renders, no extra state. The library's emitter is the source of truth; React just subscribes.
3. useIAPReady hook
// src/hooks/useIAPReady.ts
import { useEffect, useState } from 'react';
import { iap } from '../services/iap';
export function useIAPReady() {
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
iap.initialize().then(() => {
if (!cancelled) setReady(true);
});
return () => { cancelled = true; };
}, []);
return ready;
}Mount this at your app root (or a top-level provider component). initialize() is idempotent if accidentally called twice during HMR / strict-mode double-effect.
4. Boot in your app root
// src/App.tsx
import { useIAPReady } from './hooks/useIAPReady';
import { Routes } from './routes';
import { Spinner } from './components/Spinner';
export function App() {
const iapReady = useIAPReady();
if (!iapReady) return <Spinner />;
return <Routes />;
}If you want to render before init resolves (using cached entitlements), skip the <Spinner /> — entitlement reads work even before ready (they return [] until the cache hydrates, then snap to real values within ~10ms).
5. Paywall component
// src/components/Paywall.tsx
import { useState } from 'react';
import { iap } from '../services/iap';
import { useHasEntitlement } from '../hooks/useEntitlements';
export function Paywall() {
const isPremium = useHasEntitlement('premium');
const [purchasing, setPurchasing] = useState(false);
const [message, setMessage] = useState<string | null>(null);
async function onPurchase() {
setPurchasing(true);
setMessage(null);
const result = await iap.purchase('premium_monthly');
switch (result.status) {
case 'success':
setMessage('Welcome to Premium!');
break;
case 'cancelled':
break;
case 'pending':
setMessage('Payment processing — we\'ll grant access when it clears.');
break;
case 'verification_failed':
setMessage('We couldn\'t verify your purchase. We\'ll retry shortly.');
break;
case 'failed':
setMessage(`Purchase failed: ${result.error.message}`);
break;
}
setPurchasing(false);
}
return (
<div className="paywall">
<h2>Upgrade to Premium</h2>
<button onClick={onPurchase} disabled={purchasing || isPremium}>
{isPremium ? 'You\'re Premium' : 'Subscribe — $4.99/month'}
</button>
{message && <p>{message}</p>}
</div>
);
}6. Restore button
// src/components/RestoreButton.tsx
import { useState } from 'react';
import { iap } from '../services/iap';
export function RestoreButton() {
const [loading, setLoading] = useState(false);
async function onRestore() {
setLoading(true);
try {
const { restored } = await iap.restorePurchases();
alert(restored === 0 ? 'No previous purchases.' : `Restored ${restored} purchase(s).`);
} catch (error) {
alert(`Restore failed: ${(error as Error).message}`);
} finally {
setLoading(false);
}
}
return (
<button onClick={onRestore} disabled={loading}>
{loading ? 'Restoring…' : 'Restore Purchases'}
</button>
);
}7. Conditional UI
import { useHasEntitlement, useEntitlement } from './hooks/useEntitlements';
export function HomePage() {
const isPremium = useHasEntitlement('premium');
const adsRemoved = useHasEntitlement('remove_ads');
const premium = useEntitlement('premium');
return (
<div>
<FreeContent />
{isPremium && <PremiumContent />}
{!adsRemoved && <AdBanner />}
{premium?.tier === 'pro' && <ProTools />}
</div>
);
}React Native / Capacitor on web
This recipe is for Capacitor + React (running in a WebView on iOS/Android). It also works in plain React (no Capacitor) — the library detects web and falls back to no-op for purchases while keeping reads functional. Useful for Storybook, dev servers, etc.
Cleanup
useSyncExternalStore handles its own subscription cleanup. The IAP instance is a long-lived singleton; don't iap.destroy() on component unmount — only on app teardown / logout.
See also
- Vue + Quasar recipe — Pinia store wiring
- Optimistic grant — show premium UI before backend confirms
- Events — what
entitlements-changedand other events deliver