Engineering • Developer preview

React Native Analytics Setup: The Full Instrumentation Guide

Everything the write side of @clickstreamhq/react-native asks of you: one client, a screen taxonomy your dashboard can read, honest tap props, identify() at the right moment, and dev builds that never touch production data.

July 2026 • 9 min read

A React Native analytics setup usually fails slowly, not loudly. The SDK installs fine, events flow, and six months later the dashboard is unreadable: the same screen logged under three names, taps invented ad hoc by whoever shipped the feature, identify() firing at app open against nobody in particular, and TestFlight sessions quietly polluting production charts. None of those are SDK bugs. All of them are instrumentation decisions.

This guide is the decision layer for @clickstreamhq/react-native — what to call, when to call it, and the conventions that keep the data legible. It's the write-side companion to One Person Across Web and Mobile, which covers how on-device hashing resolves your app users and web visitors into the same person; we won't repeat that story here.

Version label, honestly: @clickstreamhq/react-native is a developer preview on the 0.1.0-alpha line, published under the alpha dist-tag. Every API below is the real, shipped surface — verbatim-checkable against the package — but alpha means it can change before stable. The core web package, @clickstreamhq/sdk, is stable at 1.4.0.

The Baseline: One Client, Created Once

npm install @clickstreamhq/react-native @clickstreamhq/signals @react-native-async-storage/async-storage

# Expo
npx expo install @react-native-async-storage/async-storage
// 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: "Acme/2.1.0 iOS".
  appName: 'Acme',
  appVersion: '2.1.0',
});

Three configuration choices matter more than the rest. First, the key: cs_mob_live_* is a dedicated mobile key family, exempt from the browser-provenance gate at the collector — no Origin header to fake, no pretend web URLs, real screen names on the wire. (The four scoped key families and why that exemption is safe are the subject of Analytics API Key Security.) Second, storage: the SDK falls back to an in-memory store without it, which means every cold start mints a brand-new visitor — pass AsyncStorage (or any getItem/setItem/removeItem adapter, like MMKV) in production. Third, appStateProvider: it's what makes sessions behave like sessions, and we'll come back to it.

Screen Naming: A Taxonomy Your Dashboard Can Read

On the web, the URL disciplines your data for free. In an app there is no URL — screen()'s name string becomes the path, as a free string under the collector's relaxed non-web schema. That freedom is where mobile analytics goes to die, so spend it deliberately:

// Screen view. The name IS the path — no URL required.
cs.screen('CheckoutScreen', { title: 'Checkout' });

// Hierarchical names group sections in path-style reports:
cs.screen('Settings/Notifications');

// Optional custom-scheme deep link rides in props.url:
cs.screen('ProductDetail', { title: 'Product', url: 'myapp://product/sku-123' });

Four rules keep the taxonomy legible:

tap(): Interaction Tracking Without Inventing Event Types

tap() is deliberately thin: it emits a standard custom event with category: 'tap', so interactions ride the existing custom-event pipeline rather than a parallel mobile-only schema. What's worth internalizing is how props land on the wire:

cs.tap('add_to_cart', { sku: 'SKU-123', value: 49.99 });

// Any other custom event, same pipeline, no category:
cs.trackEvent('signup_completed', { plan: 'pro' });

Two props are first-class fields: action (a string) and value (a number — use it for anything you'll want to sum or average, like cart value). Everything else is JSON-encoded into the event's label field, capped at 4,096 characters. So sku above arrives intact, but as part of a JSON bag — put the measures you'll aggregate in value, and treat the rest as context. For names, the same low-cardinality rule applies: object_action verbs like add_to_cart, start_trial, apply_coupon — never tapped_button_17.

All four write calls — screen(), tap(), trackEvent(), identify() — are fire-and-forget: they never throw into your UI and never block a render. Events buffer and flush when the buffer hits 20, every 10 seconds on a timer, and whenever the app foregrounds, splitting into the collector's 25-event cap on the wire; a bounded retry with backoff handles flaky networks, and the queue persists through your storage adapter. If you ever need a guarantee, cs.screen(...); await cs.flush() is honored — flush() waits for pending fire-and-forget work before draining.

identify() After Auth, Not at App Open

The most common instrumentation mistake we see in mobile codebases is calling identify() during app bootstrap. At app open you don't yet know who the person is — and you don't need to. The persisted visitor ID is already accruing the anonymous history; identify()'s job is to attach a verified identifier to it at the moment one actually exists:

// On login success, signup completion, or verified checkout — not at app open:
cs.identify('user@example.com', {
  phone: '+1 (415) 555-1234',
  userId: 'u_42',
});

// On logout / "forget me": clears visitor id, session, and queued events,
// then mints a fresh anonymous visitor id.
await cs.reset();

The right trigger points are the ones where the identifier is confirmed: a successful login, a completed signup, an email-verification deep link, a checkout form with an email in it. Besides phone and userId, traits accept customerId, accountId, crmContactId, and orderId for joining against your own systems. Email and phone are hashed on-device before anything leaves the app; how those hashes deterministically merge your web and mobile visitors into one person — and what happens to raw values under consent — is covered end-to-end in the cross-platform identity post.

And pair every login-side identify() with a logout-side reset(). A shared family iPad where three people take turns in your app under one visitor ID is an identity-graph mess you can avoid with one line.

Session Rotation: What AppState Actually Drives

Apps don't get closed; they get backgrounded. That's why the session model is wall-clock based: a session rotates after 30 minutes of inactivity (configurable via sessionTimeoutMs), where "activity" is any tracked event refreshing the session's last-activity timestamp. The idle check runs on every event, so rotation works even if you never pass appStateProvider.

So what does passing AppState add? A hook on the foreground transition. When the app state flips to active, the SDK does three things in order: re-hydrates its persisted state, re-evaluates the session idle window, and flushes the offline queue. The consequences are exactly what you want from mobile session semantics:

The result matches web semantics: "opened the app Tuesday morning" and "opened it again Tuesday night" are two sessions for one visitor — which is what keeps session-level metrics comparable across your web and mobile properties.

Wire the Navigator Once, Then Stop Thinking About It

Manual screen() calls scattered through components are how naming drift starts. Wire the navigation library instead, so every route logs automatically under its canonical name:

// 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>
  );
}

With nested navigators (a stack inside a tab, say), the top-level route is the tab — usually not what you want on the dashboard. Walk to the focused leaf route in your own handler, which also gives you Section/Subsection names for free:

function activeRouteName(state): string {
  const route = state.routes[state.index];
  return route.state
    ? `${route.name}/${activeRouteName(route.state)}`
    : route.name;
}

// onStateChange={(state) => state && cs.screen(activeRouteName(state))}

Once the listener is in place, reserve manual screen() calls for surfaces that don't live in navigation state — bottom sheets, full-screen modals, embedded webview steps — and name them under the same taxonomy.

Dev Builds and TestFlight Without Polluting Production

Your own team is the most dedicated user your app will ever have — and every internal session distorts engagement, intent, and abandonment charts if it lands in the production property. ClickStream's billing counts human pageviews, and your QA engineer is thoroughly human. Keep internal traffic out at the key level, not with post-hoc filters.

ClickStream's key families make the split natural. The cs_test_* prefix serves non-production website keys (the prefix is advisory — it exists so secret-scrubbing tooling can pattern-match it, and the collector treats it like cs_live_* at runtime). For a native app, the equivalent hygiene is a separate Mobile app property with its own cs_mob_live_* key for dev and TestFlight builds — key creation beyond your first is support-assisted today. Then let the build system pick:

// lib/clickstream.ts
export const cs = createClickStream({
  apiKey: __DEV__
    ? 'cs_mob_live_devproperty00'   // dev/TestFlight property — dashboards you can ignore
    : 'cs_mob_live_production000',  // production property
  endpoint: 'https://t.yourapp.com',
  storage: AsyncStorage,
  appStateProvider: AppState,
  appName: 'Acme',
  appVersion: '2.1.0',
});

__DEV__ covers simulator and dev-client builds; for TestFlight (a release build), branch on your environment config instead — whatever mechanism already distinguishes your staging API from production. The point is the shape: two properties, two keys, one of which no one ever screenshots into a board deck.

Where Signals Fits

Everything above is the write side. The read side — live behavioral scores you can use to personalize a native screen — comes pre-wired in the same package: wireSignals(cs) points the Signals visitor intelligence API at the visitor ID your tracker already persists, calling Signals' configure() under the hood with the tracker's own key and endpoint. No cookies, no DOM:

// lib/clickstream.ts (continued)
import { wireSignals } from '@clickstreamhq/react-native/signals';

// Wires resolveVisitorId/resolveSessionId to the tracker and
// runs Signals configure() with the same mobile key + endpoint.
wireSignals(cs);

Reading scores inside screens, subscribing to updates, and degrading gracefully on airplane mode are their own topic — start with the Signals getting-started guide for the read API itself and Fail-Open Personalization for the defensive patterns, both of which apply unchanged on mobile.

The Instrumentation Checklist

The SDK guarantees your events survive the subway. Only your conventions guarantee they mean something when they arrive.

Instrument Your App Properly, Once

Install the React Native developer preview, wire your navigator, and watch clean, legible mobile sessions land next to your web traffic.

Start free