@clickstreamhq/react-native
v0.1.0-alpha.1
Published
React Native + Expo SDK for ClickStream. Hermes-safe tracker (no crypto.subtle / no DOM) with screen()/tap()/identify(), offline batching, AppState session rotation, and a pre-wired @clickstreamhq/signals re-export.
Maintainers
Readme
@clickstreamhq/react-native
The React Native + Expo SDK for ClickStream. A
Hermes-safe tracker — no crypto.subtle, no DOM, no window/document — with
screen() / tap() / identify(), offline-durable batching, automatic session
rotation, and a pre-wired @clickstreamhq/signals
read API so you can personalize a screen from live visitor context.
Peer deps (optional): react, react-native · Runtime dep: @clickstreamhq/signals
No Origin spoofing. No fake web URLs. Native apps authenticate with a dedicated mobile key (
cs_mob_live_…) issued by your ClickStream dashboard. Mobile keys are exempt from the browser-provenance gate at the collector, so there is noOrigin/Refererheader to fake and no pretendhttps://page URL — you send real screen names.
Install
# npm
npm install @clickstreamhq/react-native @clickstreamhq/signals @react-native-async-storage/async-storage
# or pnpm / yarn
pnpm add @clickstreamhq/react-native @clickstreamhq/signals @react-native-async-storage/async-storage@react-native-async-storage/async-storage is what persists the visitor id,
session, and the offline event queue across app restarts. It's optional — the
SDK falls back to an in-memory store — but production apps should pass it, or
every cold start looks like a brand-new visitor.
Expo: npx expo install @react-native-async-storage/async-storage.
First 5 minutes
1. Create the client once
Create the tracker in a module so the whole app shares one instance.
// lib/clickstream.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppState } from 'react-native';
import { createClickStream } from '@clickstreamhq/react-native';
export const cs = createClickStream({
// Your dashboard's dedicated MOBILE key (Developers → Install → Mobile app).
apiKey: 'cs_mob_live_xxxxxxxxxxxx',
// Your verified first-party tracking domain (the collector). Required.
endpoint: 'https://t.yourapp.com',
// Persistence + lifecycle wiring.
storage: AsyncStorage, // survives app restarts (offline queue, visitor id)
appStateProvider: AppState, // drives 30-min idle session rotation on foreground
// Used to build device.userAgent (the field the collector reads): "Acme/2.1.0 iOS".
appName: 'Acme',
appVersion: '2.1.0',
});That's it — events now persist offline and flush automatically.
2. Track screens and taps
import { cs } from './lib/clickstream';
// Screen view. The name IS the path — no URL required.
cs.screen('CheckoutScreen', { title: 'Checkout' });
// A tap (custom event, category "tap").
cs.tap('add_to_cart', { sku: 'SKU-123', value: 49.99 });
// Any custom event.
cs.trackEvent('signup_completed', { plan: 'pro' });screen(), tap(), trackEvent(), and identify() are fire-and-forget — they
never throw into your UI and never block a render. The SDK batches them and
flushes on size, on a timer, and whenever the app returns to the foreground.
Wire screen() to your navigator so every route logs automatically:
// App.tsx — React Navigation
import { NavigationContainer } from '@react-navigation/native';
import { cs } from './lib/clickstream';
export default function App() {
return (
<NavigationContainer
onStateChange={(state) => {
const route = state?.routes[state.index];
if (route) cs.screen(route.name);
}}
>
{/* ...navigators... */}
</NavigationContainer>
);
}3. Identify a known user
Hashing runs on-device in pure JS (no native module, no WebCrypto polyfill):
email → SHA-256 (hem) + MD5 (hemMd5), phone → SHA-256 of the normalized
E.164 number. These hash identically to the web SDK, so the same person
resolves to one identity across web and mobile.
cs.identify('[email protected]', {
phone: '+1 (415) 555-1234',
userId: 'u_42',
});Raw email/phone are forwarded only when identity-resolution consent is granted (the collector encrypts them per-site behind the password-gated reveal flow). The hashes are always safe to send.
4. Consent & privacy
// Turn collection on/off at runtime. analytics:false halts ALL transport.
cs.setConsent({ analytics: true, identityResolution: true });
// On logout / "forget me": clears the visitor id, session, and queued events,
// then mints a fresh anonymous visitor id.
await cs.reset();Events recorded while analytics consent is off are dropped at call time — they
are never queued or sent, even if consent is granted a moment later.
5. Read Signals to personalize a screen
The Signals developer API gives you live visitor context — bot classification, identity status, and behavioral scores (intent, frustration, churn risk, …). The mobile package pre-wires it to the visitor id your tracker already stores, so there's no cookie or DOM involved.
// lib/clickstream.ts (continued)
import { wireSignals } from '@clickstreamhq/react-native/signals';
import { cs } from './lib/clickstream';
// Configure Signals against the same mobile key + endpoint + stored visitor id.
wireSignals(cs);// screens/Pricing.tsx
import { useEffect, useState } from 'react';
import { getVisitorOrNull, type VisitorContext } from '@clickstreamhq/react-native/signals';
export function Pricing() {
const [visitor, setVisitor] = useState<VisitorContext | null>(null);
useEffect(() => {
// Fails open to null if Signals is unavailable — your default UI still renders.
getVisitorOrNull().then(setVisitor);
}, []);
const highIntent = (visitor?.scores?.intent ?? 0) > 70;
return highIntent ? <UrgentOffer /> : <StandardPricing />;
}Subscribe to live updates while a screen is mounted:
import { onVisitor } from '@clickstreamhq/react-native/signals';
const sub = onVisitor((visitor) => {
// called on each poll with the latest snapshot
});
// later, on unmount:
sub.unsubscribe();The full Signals surface (getVisitor, getVisitorOrNull, onVisitor,
waitFor, isBot, isHighIntent, isIdentified, applySignals, …) is
re-exported from @clickstreamhq/react-native/signals, so you import from one
place.
How it works
| Concern | Behavior |
|---|---|
| Visitor id | A cs_… id minted once and persisted via your storage adapter. Survives restarts; rotates only on reset(). |
| Session | Rotates after 30 min of inactivity (configurable via sessionTimeoutMs), re-checked on every app foreground. |
| Batching | Buffers up to batchSize events (default 20), then flushes. Also flushes on a timer (flushIntervalMs, default 10s) and on foreground. The on-the-wire request is always split into the collector's 25-event cap. |
| Offline | The queue is persisted through your storage adapter. A failed flush leaves events queued; they replay on the next flush or app launch. Bounded to maxQueueSize (default 1000, oldest dropped). |
| Retry | Bounded retry with backoff on 5xx/429/network errors. A permanent 4xx drops the batch so it never jams the queue. |
| Platform | Every event sets device.clientPlatform (default 'react-native'; pass platform: 'ios' \| 'android') and device.userAgent. These are the fields the collector reads to relax the screen schema and branch native bot detection. |
Configuration
createClickStream({
apiKey: 'cs_mob_live_…', // required: dashboard mobile key
endpoint: 'https://t.…', // required: your first-party collector domain
platform: 'ios', // 'ios' | 'android' | 'react-native' (default)
storage: AsyncStorage, // StorageAdapter; default in-memory
appStateProvider: AppState, // session rotation on foreground
appName: 'Acme', // → device.userAgent
appVersion: '2.1.0', // → device.userAgent
userAgent: 'Acme/2.1 iOS', // full override of device.userAgent
viewportWidth: 390, // device.viewport.width
viewportHeight: 844, // device.viewport.height
batchSize: 20, // auto-flush buffer threshold
flushIntervalMs: 10_000, // timer flush (0 disables)
sessionTimeoutMs: 1_800_000, // 30-min idle session window
maxRetries: 5, // delivery attempts per batch
maxQueueSize: 1_000, // offline-queue ceiling
consent: { analytics: true, identityResolution: true },
});Custom storage adapter
Any object with getItem / setItem / removeItem (sync or async) works:
import { MMKV } from 'react-native-mmkv';
const mmkv = new MMKV();
createClickStream({
apiKey: 'cs_mob_live_…',
endpoint: 'https://t.yourapp.com',
storage: {
getItem: (k) => mmkv.getString(k) ?? null,
setItem: (k, v) => mmkv.set(k, v),
removeItem: (k) => mmkv.delete(k),
},
});License
MIT
