# Mobile apps: React Native and Expo

> Send screens, events, people and companies from a React Native or Expo app with @clickclacks/react-native: install, methods, consent and the offline queue.

- Canonical URL: https://clickclacks.io/docs/mobile
- Section: SDKs
- Last updated: 2026-10-01

@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.

- 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](https://clickclacks.io/docs/mobile.md#native).

## Add a mobile app source {#add-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](https://clickclacks.io/docs/mobile.md#web-builds) below.

## Install {#install}

**Expo**

```bash title="terminal"
npx expo install @clickclacks/react-native @react-native-async-storage/async-storage
```

**React Native**

```bash title="terminal"
npm install @clickclacks/react-native @react-native-async-storage/async-storage
cd ios && pod install
```

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](https://clickclacks.io/docs/mobile.md#storage) option, the SDK still works, but everything is forgotten when the app closes, so each launch looks like a new person.

## Quick start {#quick-start}

Create one client and share it:

```ts title="analytics.ts"
import { ClickClacks } from '@clickclacks/react-native'

export const clickclacks = new ClickClacks({ key: 'pk_live_3f9a1c7e5b2d4f6a8c0e1b3d' })
```

```ts title="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 {#options}

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

| Option | Default | What it does |
| --- | --- | --- |
| `key` | required | The source’s public key, `pk_live_…`. With anything else the client warns and collects nothing. |
| `host` | `https://app.clickclacks.io` | Where events go. Set it to your custom tracker domain if the project has one; the SDK adds the path. See [Custom tracker domain](https://clickclacks.io/docs/mobile.md#host). |
| `consent` | `'opt-out'` | `'opt-out'` collects until `optOut()`. `'opt-in'` collects nothing until `optIn()`. See [Consent](https://clickclacks.io/docs/mobile.md#consent). |
| `storage` | AsyncStorage | Any `{ getItem, setItem, removeItem }`. See [Storage](https://clickclacks.io/docs/mobile.md#storage). |
| `appVersion` | none | Sent as `$app_version`. |
| `appBuild` | none | Sent as `$app_build`. |
| `deviceModel` | none | Sent as `$device_model`. |
| `deviceType` | guessed | `'phone'` or `'tablet'`. Guessed from `Platform.isPad` on iOS; `'phone'` on Android. |
| `flushAt` | `25` | Queued events that trigger a send at once, 1–25. |
| `flushIntervalMs` | `5000` | Milliseconds between automatic sends. `0` turns the timer off. |
| `debug` | development only | `true` logs every problem with `console.warn`; `false` logs nothing. By default problems are logged in development builds and not in release builds. |
| `fetch` | global `fetch` | Replace it for proxies or tests. |

## Methods {#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?) {#track}

```js
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](https://clickclacks.io/docs/events.md#naming) are the same as on the web.

### screen(name, properties?) {#screen}

```js
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](https://clickclacks.io/docs/mobile.md#navigation) helper.
- Use stable names and put IDs in properties: `screen('Order')`, not `screen('Order 4821')`.

### identify(distinctId) {#identify}

```js
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`](https://clickclacks.io/docs/identify.md) 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?) {#group}

Says which company, workspace or team the person is acting for. See [Groups](https://clickclacks.io/docs/mobile.md#groups).

### reset() {#reset}

```ts title="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() {#opt-methods}

See [Consent](https://clickclacks.io/docs/mobile.md#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() {#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() {#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.

## React Navigation {#navigation}

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

```tsx title="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:

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

### Expo Router {#expo-router}

With Expo Router, report the pathname instead:

```tsx title="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 {#device}

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:

**Expo**

```ts title="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',
})
```

**React Native**

```ts title="analytics.ts"
import DeviceInfo from 'react-native-device-info'

export const clickclacks = new ClickClacks({
  key: 'pk_live_3f9a1c7e5b2d4f6a8c0e1b3d',
  appVersion: DeviceInfo.getVersion(),
  appBuild: DeviceInfo.getBuildNumber(),
  deviceModel: DeviceInfo.getModel(),
  deviceType: DeviceInfo.isTablet() ? 'tablet' : 'phone',
})
```

Android tablets are reported as `phone` unless you pass `deviceType`. The [device facts table](https://clickclacks.io/docs/mobile.md#device-facts) lists what ClickClacks does with each value.

## Consent {#consent}

> **Not legal advice**
>
> You decide which mode fits your app, your users and the law that applies to you. This section explains what the SDK does, so you and your counsel can decide.

Collection is **on by default**, the same position as the [web snippet](https://clickclacks.io/docs/consent.md). The SDK has two modes:

| Mode | What happens at launch | Use 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 is not valid prior consent in the EU or the UK**
>
> Their rules generally require consent before analytics like this starts, and that covers identifiers stored on a device, not only cookies. An app has no cookies, but it does keep IDs. If you have users there, use opt-in mode and call `optIn()` only after the person agrees.

### Opt-out mode {#opt-out-mode}

Connect your app’s analytics switch:

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

### Opt-in mode {#opt-in-mode}

```ts title="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 {#what-opt-out-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](https://clickclacks.io/docs/data.md).
- 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 {#groups}

```ts title="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](https://clickclacks.io/docs/groups.md) explains the model and what reports do with it.

## Custom tracker domain {#host}

The SDK sends to `https://app.clickclacks.io`. If your project has a [custom tracker domain](https://clickclacks.io/docs/custom-domain.md), pass it as `host`:

```ts title="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.

> **A project with a custom domain must set host**
>
> Thirty days after a custom domain goes live, `app.clickclacks.io` stops accepting that project’s events and answers `410`, for a mobile key as for a web one. An app released without `host` would stop reporting, and you can’t fix a shipped build without an update. The source’s page shows the snippet with your domain filled in.

## Storage {#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:

```ts title="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 {#delivery}

- **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 {#limits}

| Limit | Value |
| --- | --- |
| Event and screen names | 1–128 characters. Event names can’t start with `$`. |
| Properties | 2,048 bytes of JSON per event, including the SDK’s own keys |
| `identify` ID | 1–512 characters |
| Group type | 1–64 characters of `a–z`, `0–9` and `_`; at most 5 types |
| Group ID | 1–255 characters |
| Offline queue | 100 events. When it’s full, the oldest are dropped. |
| Event age | 10 minutes. Older events are dropped, not sent. |
| Request | Up to 25 events; abandoned after 15 seconds |
| Session | Ends 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 {#web-builds}

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](https://clickclacks.io/docs/install.md) 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 {#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 {#native}

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:

```http title="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" }
    }
  ]
}
```

```http title="response"
HTTP/1.1 202 Accepted
Content-Type: application/json

{ "accepted": 2 }
```

| Field | Rule |
| --- | --- |
| `key` | The mobile app source’s key, `pk_live_…`. |
| `events` | 1–25 events. Unknown fields are refused. The whole body is at most 128 KiB. |
| `id` | Optional, 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. |
| `name` | 1–128 characters. `$pageview` for a screen, `$identify`, `$group_identify`, or your own name. |
| `ts` | Optional. ISO 8601 with an offset. Kept when it is within 10 minutes of arrival; otherwise the arrival time is used. |
| `person_id` | `per_` then 1–60 characters of `A–Z a–z 0–9 _ -`. Generate one random ID per install and keep it. |
| `session_id` | `ses_` then 1–60 of the same characters. Start a new one after 30 minutes without activity. |
| `properties` | Optional 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](https://clickclacks.io/docs/custom-domain.md#what-it-serves), 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:

```json title="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 {#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.

| Property | Value | Used for |
| --- | --- | --- |
| `$lib` | `react-native`, or your own name over HTTP | Shown on the source’s App health card. |
| `$lib_version` | Up to 32 characters | The source’s page shows the SDK version last seen. |
| `$os_name` | `iOS` or `Android` | Sets `$os`, the OS reports break down by. |
| `$os_version` | Up to 32 characters | Kept as a property. |
| `$device_type` | `phone` or `tablet` | Sets `$device` to `mobile` or `tablet`, the values a browser gets. |
| `$app_version` | Up to 32 characters, optional | Kept as a property. |
| `$app_build` | Up to 32 characters, optional | Kept as a property. |
| `$device_model` | Up to 64 characters, optional | Kept as a property. |

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

### Responses {#statuses}

| HTTP | When | Retry? |
| --- | --- | --- |
| `202` | Accepted. The body is `{ "accepted": n }`. | Nothing to retry |
| `400` | The body isn’t valid: bad JSON, an unknown field, a malformed ID, more than 25 events, or properties over 2 KiB. | No |
| `403` | The key is unknown or its source was removed, or the request carried an `Origin` header. | No |
| `410` | The 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 |
| `413` | The body is over 128 KiB. | No: send fewer events |
| `503` | Collection 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](https://clickclacks.io/docs/mobile.md#consent).

## Troubleshooting {#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 {#next}

- [Identify people](https://clickclacks.io/docs/identify.md): one person across your app, your site and your backend.
- [Companies and groups](https://clickclacks.io/docs/groups.md): count companies as well as people.
- [Consent and opt-out](https://clickclacks.io/docs/consent.md): the same choices on the web.
- [Server-side](https://clickclacks.io/docs/server-side.md): events your app can’t see, from your backend.
