Mobile apps: React Native and Expo

@clickclacks/react-native sends screens, events, people and companies from your iOS and Android app into the same reports as your website. It queues events while the device is offline and has consent controls built in.

Updated

  • React Native 0.74 or later and Expo SDK 51 or later, on iOS and Android.
  • No native code, so it works in Expo Go. No runtime dependencies. ESM, CommonJS and TypeScript types included. MIT licence.
  • No method throws or rejects. Storage, validation and network problems never reach your app.
  • For a fully native Swift or Kotlin app, use the HTTP API.

Add a mobile app source

  1. In the app, open Sources and add a source. Choose Mobile app: “React Native and Expo SDK, or the HTTP API.” New projects can pick it on the welcome screen.
  2. Name it, for example “iOS and Android app”. A mobile app source has no domain.
  3. Its page shows the source key, pk_live_…, and the install snippet.

The key is public, like a website’s: it ships inside your app. Never put a secret cks_live_… server key in an app. A mobile key only works from a native app; see web builds below.

Install

terminal
npx expo install @clickclacks/react-native @react-native-async-storage/async-storage

AsyncStorage is optional but recommended. It is where the SDK keeps its IDs, the consent choice and the offline queue. Without it, and without a storage option, the SDK still works, but everything is forgotten when the app closes, so each launch looks like a new person.

Quick start

Create one client and share it:

analytics.ts
import { ClickClacks } from '@clickclacks/react-native'

export const clickclacks = new ClickClacks({ key: 'pk_live_3f9a1c7e5b2d4f6a8c0e1b3d' })
app.ts
import { clickclacks } from './analytics'

clickclacks.screen('Home')
clickclacks.track('Export finished', { rows: 12, format: 'csv' })

// After sign-in, with your own opaque user ID:
clickclacks.identify('user_8412')
clickclacks.group('company', 'cmp_311', { plan: 'pro', seats: 41 })

// On sign-out:
clickclacks.reset()

Every method returns at once and works in the background; you don’t need to await anything. Events are sent in batches of up to 25: when 25 are queued, every 5 seconds, and when the app leaves the foreground.

Options

Passed to new ClickClacks(options). The constructor never throws.

OptionDefaultWhat it does
keyrequiredThe source’s public key, pk_live_…. With anything else the client warns and collects nothing.
hosthttps://app.clickclacks.ioWhere events go. Set it to your custom tracker domain if the project has one; the SDK adds the path. See Custom tracker domain.
consent'opt-out''opt-out' collects until optOut(). 'opt-in' collects nothing until optIn(). See Consent.
storageAsyncStorageAny { getItem, setItem, removeItem }. See Storage.
appVersionnoneSent as $app_version.
appBuildnoneSent as $app_build.
deviceModelnoneSent as $device_model.
deviceTypeguessed'phone' or 'tablet'. Guessed from Platform.isPad on iOS; 'phone' on Android.
flushAt25Queued events that trigger a send at once, 1–25.
flushIntervalMs5000Milliseconds between automatic sends. 0 turns the timer off.
debugdevelopment onlytrue logs every problem with console.warn; false logs nothing. By default problems are logged in development builds and not in release builds.
fetchglobal fetchReplace it for proxies or tests.

Methods

Every method returns a promise that never rejects. track, screen, identify, group and reset resolve once the change is stored on the device, which is useful in tests.

track(name, properties?)

ts
clickclacks.track('Subscription started', { plan: 'pro', seats: 3 })

Records your own event. The name is 1–128 characters and can’t start with $. Properties are a plain object of JSON values. Naming conventions are the same as on the web.

screen(name, properties?)

ts
clickclacks.screen('Order', { order_id: 'ord_8412' })
  • Records a screen view. It is stored as a $pageview with path: '/Order' and title: 'Order', so screens appear wherever pages do in ClickClacks.
  • The name is 1–128 characters. A leading slash you supply is kept, and runs of whitespace become one space.
  • The same screen twice in a row is sent once.
  • There is no automatic screen capture. Call it yourself, or use the React Navigation helper.
  • Use stable names and put IDs in properties: screen('Order'), not screen('Order 4821').

identify(distinctId)

ts
clickclacks.identify('user_8412')
  • Ties this install to your user. It sends one $identify event and adds $distinct_id to every later event until reset().
  • Use the same ID your website passes to identify and your servers send as distinct_id, and the app, the site and the backend are one person.
  • The ID is trimmed and at most 512 characters. Calling again with the same ID sends nothing.
  • Use an opaque internal ID such as user_8412, never an email address, phone number or name.
  • It takes no traits. Send a person’s traits from your website or your backend.

group(type, id, traits?)

Says which company, workspace or team the person is acting for. See Groups.

reset()

auth.ts
async function signOut() {
  await api.signOut()
  clickclacks.reset()
}

Call it on sign-out. It forgets the identified user and the groups and starts a new anonymous person and session, so the next person to use the device isn’t mixed up with the last one. Events already queued are still sent. It doesn’t change consent. When a different user signs in on the same device, call reset() before identify().

optIn(), optOut(), hasOptedOut()

See Consent. optIn() and optOut() resolve true when the choice was stored, and false when it only holds until the app closes. hasOptedOut() resolves true whenever collection is off.

flush()

Sends what is queued now. It resolves when the attempt is over. While the SDK is backing off after a failure, flush() waits for the backoff like every other send.

shutdown()

Flushes, stops the timer and the app-state listener, and ignores every later call. You rarely need it: it’s for tests and for replacing the client.

createNavigationTracker reports the current route as a screen. It doesn’t import React Navigation; it only reads getCurrentRoute() from your navigation ref.

App.tsx
import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native'
import { createNavigationTracker } from '@clickclacks/react-native'
import { clickclacks } from './analytics'

export function App() {
  const navigationRef = useNavigationContainerRef()
  const screens = createNavigationTracker(clickclacks, navigationRef)
  return (
    <NavigationContainer ref={navigationRef} onReady={screens.onReady} onStateChange={screens.onStateChange}>
      {/* … */}
    </NavigationContainer>
  )
}
  • onReady reports the first screen, deep links included, and onStateChange every one after.
  • State changes that keep the same route send nothing.
  • Route params are never sent.

To rename or skip routes, pass screenName. Return null to skip one:

App.tsx
const screens = createNavigationTracker(clickclacks, navigationRef, {
  screenName: (route) => (route.name === 'Passcode' ? null : route.name),
})

Expo Router

With Expo Router, report the pathname instead:

app/_layout.tsx
import { usePathname } from 'expo-router'
import { useEffect } from 'react'
import { clickclacks } from './analytics'

export default function RootLayout() {
  const pathname = usePathname()
  useEffect(() => {
    clickclacks.screen(pathname)
  }, [pathname])
  // …
}

usePathname() returns the real path (/orders/8412), not the route pattern (/orders/[id]). If your paths hold IDs, build a name from useSegments() instead, so screens group together and no ID is sent.

Device and app details

A native app’s User-Agent says nothing useful, so the SDK reports the device on every event: $lib (react-native), $lib_version, $os_name (iOS or Android), $os_version and $device_type (phone or tablet).

The SDK depends on no Expo or device-info package, so the app’s version and the device model come from you:

analytics.ts
import * as Application from 'expo-application'
import * as Device from 'expo-device'

export const clickclacks = new ClickClacks({
  key: 'pk_live_3f9a1c7e5b2d4f6a8c0e1b3d',
  appVersion: Application.nativeApplicationVersion,
  appBuild: Application.nativeBuildVersion,
  deviceModel: Device.modelName,
  deviceType: Device.deviceType === Device.DeviceType.TABLET ? 'tablet' : 'phone',
})

Android tablets are reported as phone unless you pass deviceType. The device facts table lists what ClickClacks does with each value.

Collection is on by default, the same position as the web snippet. The SDK has two modes:

ModeWhat happens at launchUse it when
consent: 'opt-out'
The default.
Collection starts with the first call, unless the person opted out earlier.Your users’ law lets you measure without asking first.
consent: 'opt-in'Nothing is collected or queued, no ID is created and nothing is written to storage until optIn().People must agree first, as for most analytics in the EU and UK.

Opt-out mode

Connect your app’s analytics switch:

settings.ts
clickclacks.optOut() // stop, forget everything, and remember the refusal
clickclacks.optIn()  // start again after the person changes their mind

Opt-in mode

analytics.ts
export const clickclacks = new ClickClacks({ key: 'pk_live_3f9a1c7e5b2d4f6a8c0e1b3d', consent: 'opt-in' })

// When the person agrees in your consent screen:
clickclacks.optIn()

// When they refuse or withdraw:
clickclacks.optOut()

Before optIn() the SDK only reads its own consent record. The grant is remembered, so you don’t need to call optIn() again on later launches. A stored ID is never treated as consent.

What optOut does

  • It drops the queue, the person and session IDs, the identified user and the groups, cancels pending retries and stores the refusal.
  • The refusal is written first and the rest is cleared after, so a crash halfway leaves a refusal, never data without one.
  • A request that had already left the device can’t be recalled.
  • It stops future collection. It doesn’t delete what ClickClacks already holds; see Retention and deletion.
  • If the refusal can’t be written to storage, optOut() resolves false. Collection stays off until the app closes, but in opt-out mode it would start again on the next launch, so show the person an error and let them try again.
  • If the consent record can’t be read at launch, nothing is collected until your app calls optIn().

Groups

analytics.ts
clickclacks.group('company', 'cmp_311')                             // join
clickclacks.group('company', 'cmp_311', { plan: 'pro', seats: 41 }) // join and record traits
clickclacks.group('company', null)                                  // leave
  • type is 1–64 characters of a–z, 0–9 and _, such as company. A person can be in at most 5 types at once; a sixth is ignored.
  • id is your ID for the group, 1–255 characters. A number is sent as a string. Use an opaque ID such as cmp_311, not a domain name or an email address.
  • The groups are remembered on the device and added as $groups to every later event, until the person leaves the group, reset() or optOut().
  • Traits are sent as one $group_identify event, which is free. The newest traits replace the group’s whole set, so pass every trait each time.
  • Calling group() again with the same type, ID and traits sends nothing until 7 days have passed, so it is safe to call on every launch.
  • Traits that look personal (an email address, a phone number, or keys such as email, phone, address or password) are left off the profile unless the source allows them.
  • Invalid input is ignored without a warning.

Companies and groups explains the model and what reports do with it.

Custom tracker domain

The SDK sends to https://app.clickclacks.io. If your project has a custom tracker domain, pass it as host:

analytics.ts
export const clickclacks = new ClickClacks({
  key: 'pk_live_3f9a1c7e5b2d4f6a8c0e1b3d',
  host: 'https://stats.acme.com',
})

The SDK picks the path. On the default host it posts to /api/ingest; on any other host it posts to /e, the one path a custom domain takes events on. So pass the bare domain. A host that already ends in /e or /api/ingest is used as written.

Storage

By default the SDK uses @react-native-async-storage/async-storage when it’s installed. To use something else, pass any object with getItem, setItem and removeItem; they may return promises or plain values. For MMKV:

analytics.ts
import { MMKV } from 'react-native-mmkv'

const mmkv = new MMKV({ id: 'clickclacks' })

export const clickclacks = new ClickClacks({
  key: 'pk_live_3f9a1c7e5b2d4f6a8c0e1b3d',
  storage: {
    getItem: (key) => mmkv.getString(key) ?? null,
    setItem: (key, value) => mmkv.set(key, value),
    removeItem: (key) => mmkv.delete(key),
  },
})

The SDK writes three keys, named after your source key so two clients never collide: clickclacks:<key>:consent, clickclacks:<key>:state and clickclacks:<key>:queue.

How delivery works

  • Batching. Up to 25 events per request, sent when flushAt events are queued, every flushIntervalMs, and when the app becomes inactive or goes to the background.
  • Success. Only HTTP 202 counts.
  • Retries. Network errors, timeouts, 408, 429 and 5xx keep the events and retry after 2, 4, 8 seconds and so on, up to 60 seconds between attempts.
  • Refusals. Any other answer drops that batch, so a bad batch can’t block newer events. After 5 refused requests in a row the SDK stops sending until the app restarts. That almost always means a wrong key or host.
  • No double counting. Every event gets an id when it is queued, stored with it. A retry, also after a relaunch, sends the same IDs, and a repeat is stored once.
  • Persistence. The queue is stored after every change and restored on the next launch.

Limits

LimitValue
Event and screen names1–128 characters. Event names can’t start with $.
Properties2,048 bytes of JSON per event, including the SDK’s own keys
identify ID1–512 characters
Group type1–64 characters of a–z, 0–9 and _; at most 5 types
Group ID1–255 characters
Offline queue100 events. When it’s full, the oldest are dropped.
Event age10 minutes. Older events are dropped, not sent.
RequestUp to 25 events; abandoned after 15 seconds
SessionEnds after 30 minutes without an event
  • Events queued offline for more than 10 minutes are dropped. ClickClacks keeps an event’s own time only when it is within 10 minutes of arrival, and the SDK drops older events instead of sending them. So the SDK isn’t for long offline use. A device clock that is more than 10 minutes wrong has the same effect.
  • When an event’s properties are over 2,048 bytes, the event is still sent, without your properties. It keeps the SDK’s own keys: $distinct_id, $groups, the device details, and a screen’s path and title.
  • Group traits that are too large aren’t sent at all, because sending them empty would erase the group’s traits.

A mobile key is refused from a web page

A mobile app source’s key works only without an Origin header, which every web page sends. So a web build of the same app (Expo for web, React Native for Web) runs in a browser and is refused with a mobile key. Add a Web app source and use the browser script for the web build.

The key is public and has no list of allowed domains, so anyone who extracts it from your app can send events to that source. Removing the source revokes the key at once.

Screens in reports

  • A screen is a page view whose path is the screen name, so Overview, Flows, Retention and sessions work for your app the way they do for a website. Reports label these “Viewed page”; for a mobile source the path is your screen.
  • A screen view has no referrer and no campaign, so someone who only uses the app reads as “Direct / none” under Referrers.
  • A mobile event has no browser, so browser breakdowns show it as “(not set)”. OS and device come from the device details above.
  • In Flows, a path segment that is only digits, or eight or more hex characters, is grouped as *. Another reason to keep IDs out of screen names.
  • Heatmaps, click autocapture and bot filtering are for websites; a mobile source has none of them.
  • The source’s page has an App health card: when the last event arrived, events in the last 24 hours, the SDK version last seen and the split between iOS and Android.

Not collected: automatic screen views or taps, session replay, crash reports and push notification data.

Native Swift and Kotlin apps

There is no native Swift or Kotlin SDK. A fully native app sends the same events the React Native SDK does, over HTTP, with a mobile app source’s public key:

request
POST /api/ingest HTTP/1.1
Host: app.clickclacks.io
Content-Type: application/json

{
  "key": "pk_live_3f9a1c7e5b2d4f6a8c0e1b3d",
  "events": [
    {
      "id": "evt_0123456789abcdef0123456789abcdef",
      "name": "$pageview",
      "ts": "2026-10-01T12:00:00.000Z",
      "person_id": "per_k3J9sQ1xR2",
      "session_id": "ses_7fQ2mB81",
      "properties": {
        "path": "/Home",
        "title": "Home",
        "$lib": "my-swift-client",
        "$lib_version": "1.0.0",
        "$os_name": "iOS",
        "$os_version": "18.1",
        "$device_type": "phone",
        "$app_version": "2.4.0"
      }
    },
    {
      "id": "evt_fedcba9876543210fedcba9876543210",
      "name": "Checkout started",
      "ts": "2026-10-01T12:00:04.000Z",
      "person_id": "per_k3J9sQ1xR2",
      "session_id": "ses_7fQ2mB81",
      "properties": { "plan": "pro", "$groups": { "company": "cmp_311" }, "$os_name": "iOS", "$device_type": "phone" }
    }
  ]
}
response
HTTP/1.1 202 Accepted
Content-Type: application/json

{ "accepted": 2 }
FieldRule
keyThe mobile app source’s key, pk_live_….
events1–25 events. Unknown fields are refused. The whole body is at most 128 KiB.
idOptional, and recommended: evt_ then 8–80 characters of A–Z a–z 0–9 _ -. Create it with the event and send the same one when you retry, so a repeat is stored once.
name1–128 characters. $pageview for a screen, $identify, $group_identify, or your own name.
tsOptional. ISO 8601 with an offset. Kept when it is within 10 minutes of arrival; otherwise the arrival time is used.
person_idper_ then 1–60 characters of A–Z a–z 0–9 _ -. Generate one random ID per install and keep it.
session_idses_ then 1–60 of the same characters. Start a new one after 30 minutes without activity.
propertiesOptional JSON object, at most 2 KiB.
  • Send no Origin header. The endpoint sends no CORS headers for a mobile key and refuses its preflight.
  • With a custom tracker domain, post the same body to https://<your domain>/e, the one path a custom domain takes events on (the SDK does this for you). Thirty days after the domain goes live, app.clickclacks.io answers 410 for that project.
  • Screens are $pageview events with a URL-shaped path (/Home) and a title.
  • Identify and company traits are events too:
events
{ "name": "$identify", "person_id": "per_k3J9sQ1xR2", "session_id": "ses_7fQ2mB81",
  "properties": { "distinct_id": "user_8412" } }

{ "name": "$group_identify", "person_id": "per_k3J9sQ1xR2", "session_id": "ses_7fQ2mB81",
  "properties": { "$group_type": "company", "$group_id": "cmp_311", "plan": "pro" } }

An event counts for a company when its properties carry $groups, as in the request above. ClickClacks sets $country, $os, $device, $source_id and $ingest_host itself; if you send those keys, or $browser, they are dropped.

Device facts

Report the device on every event. A fact with a value outside the rule is dropped silently and the event is kept. Values are strings: a number, such as "$app_build": 412, is dropped.

PropertyValueUsed for
$libreact-native, or your own name over HTTPShown on the source’s App health card.
$lib_versionUp to 32 charactersThe source’s page shows the SDK version last seen.
$os_nameiOS or AndroidSets $os, the OS reports break down by.
$os_versionUp to 32 charactersKept as a property.
$device_typephone or tabletSets $device to mobile or tablet, the values a browser gets.
$app_versionUp to 32 characters, optionalKept as a property.
$app_buildUp to 32 characters, optionalKept as a property.
$device_modelUp to 64 characters, optionalKept as a property.

Country comes from the request’s IP address. The address itself is kept only when the source records IP addresses.

Responses

HTTPWhenRetry?
202Accepted. The body is { "accepted": n }.Nothing to retry
400The body isn’t valid: bad JSON, an unknown field, a malformed ID, more than 25 events, or properties over 2 KiB.No
403The key is unknown or its source was removed, or the request carried an Origin header.No
410The project has a custom tracker domain that has been live for more than 30 days: send to that host instead of app.clickclacks.io.No: change the host
413The body is over 128 KiB.No: send fewer events
503Collection is briefly unavailable.Yes, after Retry-After seconds

Queue events on the device, send them in batches, and keep a batch until it is answered 202. Retry network errors and 503 with backoff; never retry 400, 403 or 410. For consent, keep collection off until the person agrees wherever prior consent is required, as above.

Troubleshooting

Turn on debug: true to see every problem in the console.

  • Nothing arrives. Check that the key is the mobile app source’s pk_live_… key, and the host if you set one. In opt-in mode, check that optIn() was called. await clickclacks.hasOptedOut() tells you whether collection is off.
  • “stopped sending after 5 refused requests”. ClickClacks refused five requests in a row. Check the key and the host, then restart the app.
  • Every launch is a new person. AsyncStorage isn’t installed, or wasn’t linked (bare React Native: run pod install and rebuild). Or pass a storage adapter.
  • Events stopped arriving and the project has a custom tracker domain. The default host answers 410 for a project 30 days after its custom domain goes live. Set host to the domain and ship an update.
  • 404 from a custom tracker domain. It only accepts POST /e. Pass the bare domain as host, such as https://stats.acme.com, and the SDK adds /e.
  • Events are missing after the device was offline. Events older than 10 minutes are dropped.
  • Fewer screen views than expected. The same screen twice in a row counts once. Going Home, Settings, Home is three screen views.
  • Android tablets show as phones. Pass deviceType.
  • Jest. Mock AsyncStorage with its official Jest mock, or pass an in-memory storage.

Next steps