Engineering

React Native Personalization with ClickStream Signals

wireSignals(cs) points the Signals client at the visitor id your mobile tracker already stores. Then getVisitorOrNull() on screen focus personalizes native screens — and the default screen still renders in airplane mode.

July 2026 • 8 min read • Developer preview (0.1.0-alpha)

@clickstreamhq/react-native is a developer preview, published to npm at 0.1.0-alpha.2 alongside @clickstreamhq/signals at 0.1.0-alpha.8. Every API in this article is the real, current surface — verbatim-checkable against the packages — but expect it to evolve before a stable release, and pin your versions. Of the ClickStream packages, only @clickstreamhq/sdk (1.4.0) is stable today.

What React Native Personalization Means Here

React Native personalization, as this article uses the term, is narrow and concrete: while a native screen is focused, read the current visitor's live behavioral context — intent, frustration, engagement, decision stage — and let the screen respond before the session is over. Not a nightly segment export, not a push-notification audience. One REST read against /v1/signals/:visitorId, documented at roughly 50–150 ms, answered by the same Signals surface web pages use.

The problem is that @clickstreamhq/signals was born runtime-agnostic but defaults to a browser: with no custom resolvers, it finds the visitor id through the window.clickstream bridge, then the _cs_vid cookie, then localStorage. None of that exists in Hermes. There is no DOM, no cookie jar, no bridge — just the visitor id your mobile tracker minted and persisted through AsyncStorage.

The @clickstreamhq/react-native/signals subpath closes that gap with one function, and this article is the tour: wire it once, read on screen focus, subscribe while mounted, and treat airplane mode as the design center rather than the edge case.

wireSignals(cs): configure({ apiKey }) for a Runtime with No Cookies

Install the tracker, the signals peer, and the storage adapter:

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

Then create the client once, in a module the whole app shares, and wire Signals to it:

// lib/clickstream.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppState } from 'react-native';
import { createClickStream } from '@clickstreamhq/react-native';
import { wireSignals } from '@clickstreamhq/react-native/signals';

export const cs = createClickStream({
  apiKey: 'cs_mob_live_xxxxxxxxxxxx', // dedicated mobile key — not your web cs_live_* key
  endpoint: 'https://t.yourapp.com',  // your verified first-party collector domain
  storage: AsyncStorage,              // visitor id + offline queue survive restarts
  appStateProvider: AppState,         // 30-min idle session rotation on foreground
  appName: 'Acme',
  appVersion: '2.1.0',
});

// Signals now reads with the same key, endpoint, and stored visitor id.
wireSignals(cs);

The signals invariant — configure({ apiKey }) before any getVisitor() or getVisitorOrNull() read — still holds on mobile. wireSignals satisfies it for you: it reads the API key and endpoint off the tracker (throwing an immediate TypeError if it can't resolve either, so misconfiguration fails at startup, not mid-session) and calls the signals client's configure() with live resolvers:

signalsConfigure({
  ...rest,
  apiKey,
  endpoint,
  // Live resolvers: always read the tracker's current ids (never a snapshot),
  // so post-reset() or session rotation are picked up automatically.
  resolveVisitorId: () => tracker.getVisitorId() ?? undefined,
  resolveSessionId: () => tracker.getSessionId() ?? undefined,
});

That comment is doing real work. The resolvers are callbacks, not captured values — so when the user hits “forget me” and cs.reset() mints a fresh anonymous visitor, or the session rotates after 30 idle minutes, the next Signals read automatically targets the new ids. unwireSignals() tears the client down again if you need a clean slate.

Two details worth naming. First, the key: mobile apps authenticate with a dedicated cs_mob_live_* key that is exempt from the browser Origin/Referer provenance gate at the collector — a native app never fakes a web page URL. The key families and domain gating article covers why the families exist. Second, the subpath re-exports the entire signals surface — getVisitor, getVisitorOrNull, onVisitor, subscribeVisitor, waitFor, the helper predicates, the error classes, even applySignals — so your app imports from @clickstreamhq/react-native/signals and nowhere else.

Read on Screen Focus with getVisitorOrNull()

On the web, the natural read point is page load. In a native app it's screen focus — the moment a screen becomes the one the user is looking at. With React Navigation that's useFocusEffect:

// screens/Home.tsx
import { useCallback, useState } from 'react';
import { useFocusEffect } from '@react-navigation/native';
import {
  getVisitorOrNull,
  isHighIntent,
  type VisitorContext,
} from '@clickstreamhq/react-native/signals';

export function HomeScreen() {
  const [visitor, setVisitor] = useState<VisitorContext | null>(null);

  useFocusEffect(
    useCallback(() => {
      let active = true;
      // Fails open: resolves null offline or when Signals is unavailable,
      // so the default module order renders untouched.
      getVisitorOrNull().then((v) => { if (active) setVisitor(v); });
      return () => { active = false; };
    }, []),
  );

  const modules = [...DEFAULT_MODULES];
  if (visitor && isHighIntent(visitor)) {
    // scores.intent >= 70: surface the resume-order module first.
    modules.unshift(RESUME_ORDER_MODULE);
  }
  if (visitor?.scores.decisionStage === 'comparing') {
    modules.splice(1, 0, COMPARISON_MODULE);
  }
  return <ModuleList modules={modules} />;
}

This is the intent-aware home screen pattern: the screen always has a complete default, and the snapshot only reorders and adds. A returning visitor whose scores.intent is in the high band (the isHighIntent helper encodes the canonical 70+ threshold) sees the resume-order module first; a visitor whose scores.decisionStage is comparing gets the comparison module promoted. Everyone else — including every visitor whose read returned null — gets the screen you designed anyway. The full snapshot shape (nine numeric scores plus the categorical emotionalState and decisionStage) is documented field-by-field in Getting Started with the Signals API, and the two categorical fields get their own treatment in Beyond the Score.

One guardrail carries over from web unchanged: personalize emphasis and ordering, never price or availability. Don't show different prices, hide plans, or gate discounts by score — that's the dark-pattern line, and it holds on mobile exactly as it does in the browser.

Live Updates While a Screen Stays Mounted

A one-shot read answers “who is this visitor when the screen appears.” For a screen the user sits on — a long configuration flow, a cart — subscribe instead, and unsubscribe on blur:

import { onVisitor } from '@clickstreamhq/react-native/signals';

useFocusEffect(
  useCallback(() => {
    const sub = onVisitor((v) => setVisitor(v)); // fires immediately, then polls
    return () => sub.unsubscribe();              // stop polling when the screen blurs
  }, []),
);

onVisitor() polls at a configurable interval (default 2,000 ms, floor 1,000) and survives transient failures — a failed tick warns and keeps going. When polling isn't fast enough, subscribeVisitor() opens a visitor-scoped WebSocket stream, and it works in React Native because Hermes ships a global WebSocket. Two mobile-specific notes: realtime is Scale and above, and it requires a session id — which wireSignals already provides through resolveSessionId, so the RN call looks exactly like the web one:

import { subscribeVisitor } from '@clickstreamhq/react-native/signals';

const sub = subscribeVisitor(
  (v) => {
    if (v.stale) return;
    setVisitor(v);
  },
  { fallbackToPolling: true }, // the default: degrade to polling if the stream is capped or rejected
);

One porting note for teams arriving from the web SDK: applySignals rules run fine in Hermes when your run callbacks drive component state, but the built-in effects helpers (effects.show/hide/addClass…) query the DOM at call time and are deliberate no-ops when no document exists. In React Native, prefer plain run callbacks — or skip applySignals entirely and branch in JSX, which is what the examples here do. (On web, the same state-not-DOM job belongs to the React hooks package.)

Frustration-Aware Support Entry

The second pattern worth shipping first: make in-app support easier to reach for exactly the visitors who need it. isFrustrated encodes the canonical 60+ threshold on scores.frustration; pairing it with a negative sessionMomentum (the one score that runs −100…100, where below zero means the session is trending worse) keeps the banner away from people who hit one snag and recovered:

import { isFrustrated } from '@clickstreamhq/react-native/signals';

const elevateSupport =
  visitor !== null &&
  isFrustrated(visitor) &&              // scores.frustration >= 60
  visitor.scores.sessionMomentum < 0;   // and the session is trending down

return (
  <CheckoutForm
    footer={
      elevateSupport
        ? <SupportBanner label="Stuck? Talk to a human" screen="Checkout" />
        : <SupportLink />               // the default, always-present entry point
    }
  />
);

Note what this does not do: it never removes the default support link. Frustration-aware UI promotes the escape hatch; it doesn't create one that otherwise wouldn't exist. The same scores can also route the resulting conversation — the web version of that play, including handing the agent the visitor's context, is Route Frustrated Visitors Before They Rage.

Airplane Mode Is the Default Case, Not the Edge Case

Mobile networks fail constantly, so the fail-open contract matters more here than anywhere. It has three layers:

If a rule of thumb helps: the snapshot is a bonus, the default screen is the product. The full defensive playbook — stale reuse windows, visitor.pending, distinguishing a warming-up visitor from an exhausted budget — is in Fail-Open Personalization Patterns, and it applies to Hermes unchanged.

An Honest Note on Bot Fields Inside an App

The VisitorContext you get on mobile is the same shape as on web, so it still carries visitor.bot.isBot, visitor.bot.category, visitor.bot.name, and visitor.behavioralClass. Inside an installed native app, those fields are mostly irrelevant — and it's worth saying so plainly. ClickStream's bot classification is built for browser traffic: crawler user agents, headless browsers, provenance checks. Nobody's search crawler is browsing your iOS app, and the collector deliberately skips browser-only bot heuristics for events tagged with device.clientPlatform. Expect in-app snapshots to read isBot: false, human, essentially always.

So don't build in-app logic around bot fields; there's no signal there to use. Where they earn their keep is the rest of your property: the same visitor identity spans your web and mobile surfaces (that mechanism — identical email and phone hashing on-device — is covered in Cross-Platform Identity in React Native), and on the web side, bot and behavioral classification do heavy lifting. In the app itself, spend your snapshot on the scores.

The Bottom Line

The whole mobile personalization loop is four moves:

A personalized native screen is just your default screen, plus a snapshot that showed up in time. Build the default first — Signals only ever makes it better.

Personalize Your First Screen

Create a mobile key, wire the tracker, and reorder one home-screen module on a real intent score. If the read fails, your default screen renders anyway — that's the point.

Start free