---
title: "How to add product analytics to a React Native app"
description: "Track report opens and date-range changes in React Native with Amplitude. Preserve anonymous identity and connect verified accounts after sign-in."
canonical: "https://amplitude.com/en-us/build/react-native-product-analytics"
---

# How to add product analytics to a React Native app

Track report opens and date-range changes in React Native with Amplitude. Preserve anonymous identity and connect verified accounts after sign-in.

Source: https://amplitude.com/en-us/build/react-native-product-analytics

---

# How to add product analytics to a React Native app

Track report opens and date-range changes with Amplitude's mobile SDK, then connect those events to your existing sign-in flow. Allow about 25 minutes after native development tools are installed.

- React Native 0.88
- Community CLI
- React Native SDK 1.x
- 25 min

Chris Tam · Last updated 26 October 2026

React Native runs one JavaScript app across native Android and iOS builds. I use [product analytics](https://amplitude.com/amplitude-analytics) to see when someone opens a usage report and changes its date range.

In this tutorial, you'll install the mobile SDK, track two report actions, preserve anonymous identity, and connect verified accounts after sign-in.

A native report screen with Open report and Change date range buttons. Each action queues a named Amplitude event with report\_type, days and platform. The standalone demo runs anonymously; its account adapter plugs into an existing verified authentication flow.

## Prerequisites

- Node.js 22.13+ in the 22.x series, 24.3+ in the 24.x series, or Node.js 26+; pnpm 10.33.2. The sample pins React Native 0.88.0 and React 19.3.0.
- An Android emulator/device with the Android development toolchain, or macOS with Xcode, an iOS simulator/device, Ruby Bundler and CocoaPods. Follow [React Native environment setup](https://reactnative.dev/docs/set-up-your-environment) before building.
- An Amplitude project API key from [Organization settings > API Keys](https://amplitude.com/docs/admin/account-management/manage-your-api-keys-and-secret-keys); select your project. For signed-in testing, use your app's existing verified authentication and canonical user IDs of at least five characters.

No Amplitude account yet? A free account is enough for the explicit events in this tutorial. [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

## 1. Create the native app and install dependencies

I start with the [React Native Community CLI](https://reactnative.dev/docs/getting-started-without-a-framework) so the project includes native Android and iOS files. The [mobile installation guide](https://amplitude.com/docs/get-started/install-mobile-sdk#react-native) supplies the Amplitude SDK and AsyncStorage dependency. A fresh project does not need another Build tutorial.

**Terminal, new project**

Terminal, new project

```sh
mkdir -p amplitude-react-native/tooling
```

Save the next two files in that tooling directory. They pin pnpm and configure its dependency safeguards. The command below applies them to the CLI package fetched by pnpm. The CLI separately downloads its native template through npm; these pnpm settings do not govern that internal template download. The application dependency install later in this step uses pnpm and its safeguards.

**tooling/package.json**

tooling/package.json

```json
{
  "name": "react-native-build-checks",
  "private": true,
  "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
}
```

**tooling/pnpm-workspace.yaml**

tooling/pnpm-workspace.yaml

```yaml
minimumReleaseAge: 10080
trustPolicy: no-downgrade
```

**Terminal, create the app**

Terminal, create the app

```sh
cd amplitude-react-native/tooling
pnpm --config.minimumReleaseAge=10080 --config.trustPolicy=no-downgrade dlx @react-native-community/cli@20.2.0 init AmplitudeReports --version 0.88.0 --skip-install --skip-git-init
cd AmplitudeReports
```

The initializer supplies android/, ios/, Gemfile and the native entry points. Keep those generated files. Save the following application files in AmplitudeReports, then install. For an existing app, skip initialization and merge the dependencies, entry-point changes and analytics modules into its current setup; preserve authentication, native settings, routes and instrumentation.

**package.json**

package.json

```json
{
  "name": "AmplitudeReports",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "android": "react-native run-android",
    "ios": "react-native run-ios",
    "start": "react-native start",
    "test": "jest",
    "typecheck": "tsc --noEmit",
    "bundle:android": "react-native bundle --platform android --dev false --entry-file index.js --bundle-output artifacts/index.android.bundle --assets-dest artifacts/android"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-native": "0.88.0",
    "@react-native/new-app-screen": "0.88.0",
    "react-native-safe-area-context": "^5.5.2",
    "@amplitude/analytics-react-native": "1.12.3",
    "@react-native-async-storage/async-storage": "1.24.0"
  },
  "devDependencies": {
    "@babel/core": "^7.25.2",
    "@babel/preset-env": "^7.25.3",
    "@babel/runtime": "^7.25.0",
    "@react-native-community/cli": "20.2.0",
    "@react-native-community/cli-platform-android": "20.2.0",
    "@react-native-community/cli-platform-ios": "20.2.0",
    "@react-native/babel-preset": "0.88.0",
    "@react-native/jest-preset": "0.88.0",
    "@react-native/metro-config": "0.88.0",
    "@react-native/typescript-config": "0.88.0",
    "@types/jest": "^29.5.13",
    "@types/react": "^19.2.0",
    "@types/react-test-renderer": "^19.1.0",
    "jest": "^29.6.3",
    "react-test-renderer": "19.3.0",
    "typescript": "^6.0.3"
  },
  "engines": {
    "node": "^22.13.0 || ^24.3.0 || >=26.0.0"
  },
  "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
}
```

**pnpm-workspace.yaml**

pnpm-workspace.yaml

```yaml
minimumReleaseAge: 10080
trustPolicy: no-downgrade
nodeLinker: hoisted
overrides:
  "semver@6.3.1": "7.8.5"
```

The sample omits the template's optional ESLint and Prettier tooling. Keep your lint setup in an existing app. The workspace uses an explicit major-version override from semver 6.3.1 to 7.8.5; keep it with this setup and retest it when updating Babel or React Native. pnpm still checks the replacement release against the same age and trust policies.

**app.json**

app.json

```json
{
  "name": "AmplitudeReports",
  "displayName": "AmplitudeReports"
}
```

**index.js**

index.js

```javascript
/**
 * @format
 */

import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';

AppRegistry.registerComponent(appName, () => App);
```

**babel.config.js**

babel.config.js

```javascript
module.exports = {
  presets: ['module:@react-native/babel-preset'],
};
```

**metro.config.js**

metro.config.js

```javascript
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');

/**
 * Metro configuration
 * https://reactnative.dev/docs/metro
 *
 * @type {import('@react-native/metro-config').MetroConfig}
 */
const config = {};

module.exports = mergeConfig(getDefaultConfig(__dirname), config);
```

**tsconfig.json**

tsconfig.json

```json
{
  "extends": "@react-native/typescript-config",
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["**/node_modules", "**/Pods"]
}
```

**jest.config.js**

jest.config.js

```javascript
module.exports = {
  preset: '@react-native/jest-preset',
};
```

**Terminal, install**

Terminal, install

```sh
pnpm install
```

A dependency trust or release-age error needs investigation; keep the safeguards enabled. The lockfile created by the successful install belongs with this project. Use pnpm install --frozen-lockfile for later reproducible installs.

## 2. Initialize one analytics client

I initialize before instrumentation and share its ready promise. Set apiKey to your project's key in the configuration file. A project API key ships with a mobile app; management credentials stay on your server. Use EU only with an EU project's key, as described in [EU data residency](https://amplitude.com/docs/sdks/analytics/react-native/react-native-sdk#eu-data-residency).

**src/amplitude-config.ts**

src/amplitude-config.ts

```typescript
// Project API keys identify a project; never put a secret management key here.
export const amplitudeConfig = {
  apiKey: '',
  serverZone: 'US' as 'US' | 'EU',
};
```

**src/analytics.ts**

src/analytics.ts

```typescript
// Keep any replay, experiment and engagement integrations when merging this file.
import * as amplitude from '@amplitude/analytics-react-native';
import {Platform} from 'react-native';
import {amplitudeConfig} from './amplitude-config';

let ready: Promise<boolean> | undefined;

export function startAnalytics(): Promise<boolean> {
  if (!ready) {
    ready = (async () => {
      if (!amplitudeConfig.apiKey.trim()) {
        return false;
      }
      await amplitude.init(amplitudeConfig.apiKey, undefined, {
        serverZone: amplitudeConfig.serverZone,
        autocapture: false,
      }).promise;
      return true;
    })().catch(() => {
      ready = undefined;
      return false;
    });
  }
  return ready;
}

async function reportEvent(name: string, days: number): Promise<void> {
  if (!(await startAnalytics())) {
    return;
  }
  // Queue in the background; tracking does not control whether the report renders.
  void amplitude.track(name, {
    report_type: 'usage',
    days,
    platform: Platform.OS,
  }).promise.catch(() => undefined);
}

export function reportPreviewed(days: number): Promise<void> {
  return reportEvent('Report Previewed', days);
}

export function reportRangeChanged(days: number): Promise<void> {
  return reportEvent('Report Range Changed', days);
}

export async function flushAnalytics(): Promise<void> {
  if (await startAnalytics()) {
    await amplitude.flush().promise;
  }

export function analyticsDeviceId(): string | undefined {
  return amplitude.getDeviceId();
}
```

If your existing app already initializes Amplitude, reuse its current client and ready promise instead of adding a second initialization. The empty-key guard leaves the report usable while analytics is off. With a key, this demo chooses autocapture: false and instruments the two report actions explicitly. The account adapter also sends Identify calls for signed-in account properties. The SDK handles its normal batching; the helper does not make report rendering wait for an upload. Follow your existing consent flow when deciding when initialization may run.

**Why.** Initialize once before calling track. Awaiting the module's initialization promise establishes ordering; it does not establish that Amplitude has processed an event.

## 3. Connect verified account changes

I keep account identity in one adapter. The standalone app passes null because it has no authentication system. In an existing signed-in app, call this function with the account returned by your verified session flow, after that flow finishes. Call it with null after confirmed sign-out; never take an account ID from an arbitrary input field.

**src/account-analytics.ts**

src/account-analytics.ts

```typescript
// Pass accounts from your existing verified authentication flow.
import * as amplitude from '@amplitude/analytics-react-native';
import {startAnalytics} from './analytics';

export type VerifiedAccount = {id: string; plan: string};

// Call from your app's existing verified sign-in/sign-out flow.
export async function syncAnalyticsAccount(account: VerifiedAccount | null): Promise<void> {
  if (!(await startAnalytics())) {
    return;
  }
  const previous = amplitude.getUserId();
  if (previous && previous !== account?.id) {
    amplitude.reset();
  }
  if (account) {
    amplitude.setUserId(account.id);
    void amplitude.identify(new amplitude.Identify().set('plan', account.plan)).promise.catch(() => undefined);
  }
```

Anonymous events retain the SDK's device ID. First sign-in adds the canonical user ID without resetting that device. Switching from one known account to another, or signing out, resets the previous identity before another account is applied. Repeated anonymous synchronization does not keep rotating the device ID. The plan property comes from the verified account.

**Why.** Do not copy the demo's null call over your existing authentication. Wait for the verified session result and pass that account before allowing its tracked actions. If your app has replay, flags or engagement cleanup, retain their documented sign-out sequence around this adapter.

## 4. Track the report actions

The two buttons are the event boundaries. I update the local report immediately and queue analytics afterward. The counts are sample data generated inside the screen; replace that report content with your application's data without changing its identity boundary.

**App.tsx**

App.tsx

```tsx
// For an existing app, merge the report and lifecycle handler into its current root.
import React, {useEffect, useState} from 'react';
import {AppState, Button, ScrollView, StyleSheet, Text, View} from 'react-native';
import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context';
import {
  analyticsDeviceId,
  flushAnalytics,
  reportPreviewed,
  reportRangeChanged,
  startAnalytics,
} from './src/analytics';
import {syncAnalyticsAccount} from './src/account-analytics';

export default function App() {
  const [days, setDays] = useState(7);
  const [opened, setOpened] = useState(false);
  const [status, setStatus] = useState('Starting analytics');
  const [reportReady, setReportReady] = useState(false);
  const [deviceId, setDeviceId] = useState<string>();

  useEffect(() => {
    let mounted = true;
    void startAnalytics().then(async enabled => {
      // The clean demo is anonymous. Existing apps pass their verified account here.
      await syncAnalyticsAccount(null);
      if (mounted) {
        setStatus(enabled ? 'Analytics initialized' : 'Analytics is off. Check your project key and SDK logs.');
        setReportReady(true);
        setDeviceId(analyticsDeviceId());
      }
    });
    const subscription = AppState.addEventListener('change', next => {
      if (next === 'background') {
        // Best effort only: the operating system can suspend JavaScript immediately.
        void flushAnalytics().catch(() => undefined);
      }
    });
    return () => {
      mounted = false;
      subscription.remove();
    };
  }, []);

  function openReport() {
    setOpened(true);
    void reportPreviewed(days);
  }

  function changeRange() {
    const nextDays = days === 7 ? 30 : 7;
    setDays(nextDays);
    void reportRangeChanged(nextDays);
  }

  return (
    <SafeAreaProvider>
      <SafeAreaView style={styles.screen}>
        <ScrollView contentContainerStyle={styles.content}>
          <Text style={styles.heading}>Usage report</Text>
          <Text>{status}</Text>
          <Button title="Open report" onPress={openReport} disabled={!reportReady} />
          {opened && (
            <View style={styles.report}>
              <Text>Last {days} days</Text>
              <Text>Demo activity: {days * 12} report actions</Text>
              <Button title="Change date range" onPress={changeRange} />
            </View>
          )}
          {__DEV__ && deviceId && <Text selectable>Device ID: {deviceId}</Text>}
        </ScrollView>
      </SafeAreaView>
    </SafeAreaProvider>
  );
}

const styles = StyleSheet.create({
  screen: {flex: 1, backgroundColor: '#FFFFFF'},
  content: {padding: 24, gap: 20},
  heading: {fontSize: 28, color: '#000000'},
  report: {gap: 12},
});
```

The root initializes the client, synchronizes the anonymous demo account and enables its buttons after that setup. In an existing app, merge this lifecycle handler into the current root instead of replacing it. The background handler requests a flush on a real background transition. A mobile operating system can suspend JavaScript immediately, so this is best effort; a returned call is not a delivery guarantee.

The generated sample UI test needs the report adapters mocked so it does not depend on a native SDK bridge or contact Amplitude. This test checks button wiring only.

**\_\_tests\_\_/App.test.tsx**

\_\_tests\_\_/App.test.tsx

```tsx
import React from 'react';
import {Button} from 'react-native';
import renderer, {act} from 'react-test-renderer';
import App from '../App';
import {reportPreviewed, reportRangeChanged} from '../src/analytics';

jest.mock('../src/analytics', () => ({
  startAnalytics: jest.fn(async () => true),
  analyticsDeviceId: jest.fn(() => 'test-device-123'),
  reportPreviewed: jest.fn(async () => undefined),
  reportRangeChanged: jest.fn(async () => undefined),
  flushAnalytics: jest.fn(async () => undefined),
}));
jest.mock('../src/account-analytics', () => ({
  syncAnalyticsAccount: jest.fn(async () => undefined),
}));
jest.mock('react-native-safe-area-context', () => ({
  SafeAreaProvider: ({children}: {children: React.ReactNode}) => children,
  SafeAreaView: require('react-native').View,
}));

test('report actions call their analytics adapters', async () => {
  let app: renderer.ReactTestRenderer;
  await act(async () => {
    app = renderer.create(<App />);
  });
  await act(async () => {
    app!.root.findAllByType(Button).find(button => button.props.title === 'Open report')!.props.onPress();
  });
  expect(reportPreviewed).toHaveBeenCalledWith(7);
  await act(async () => {
    app!.root.findAllByType(Button).find(button => button.props.title === 'Change date range')!.props.onPress();
  });
  expect(reportRangeChanged).toHaveBeenCalledWith(30);
  await act(async () => app!.unmount());
});
```

## 5. Build and run a native app

Rebuild after installing native modules. Fast Refresh updates JavaScript and cannot add a missing native SDK module to an installed binary.

**Terminal, Android**

Terminal, Android

```sh
pnpm start
# In a second terminal inside AmplitudeReports:
pnpm android
```

**Terminal, iOS on macOS**

Terminal, iOS on macOS

```sh
bundle install
cd ios
bundle exec pod install
cd ..
pnpm start
# In another terminal inside AmplitudeReports:
pnpm ios
```

The Gemfile comes from the generated template. Keep the native project name AmplitudeReports consistent with app.json and index.js. In an existing Expo app, use a development/native build with these modules installed; the Amplitude reference does not support Expo Go for this SDK.

## Verify it worked

1. Set the project API key, rebuild the native app and open it. The screen should say Analytics initialized. That confirms initialization completed, not delivery.
2. Tap Open report, then Change date range. Confirm the screen switches from seven to 30 days. These actions should queue Report Previewed with days=7 and Report Range Changed with days=30; both include report\_type=usage and the actual platform.
3. In development, copy the device ID shown on the screen. Open Amplitude's Ingestion Debugger and check for rejected uploads, then use Users > User Profiles to find that device's event stream. Allow the documented processing time; an upload response and a processed profile are different checks. The [debugging guide](https://amplitude.com/docs/analytics/debug-analytics) describes those tools.
4. Inspect the two events' Raw data. Confirm their event names, properties and device ID. With the demo's anonymous adapter, no fabricated user ID should appear.
5. In an existing app, test its real sign-in and sign-out flow with two accounts. Check the canonical user ID and plan after sign-in, identity isolation after account switching, and a new anonymous device after sign-out. Do not use test input fields as authentication.
6. Background and reopen the app. Verify later report actions still arrive. Check offline/reconnect behavior on an actual device separately; a successful JavaScript build does not establish native lifecycle or persistence behavior.

## Troubleshooting

**The report works, but analytics is off.** Set the project API key in src/amplitude-config.ts and reload the app. Confirm the key belongs to the selected US/EU region. Check SDK warnings without logging private event contents.

**A native module is missing.** Install the dependencies, run CocoaPods for iOS, and rebuild the native binary. Reloading JavaScript alone cannot link native code. For Expo, use a development build rather than Expo Go.

**Events appear under the previous account.** Connect the current verified authentication result before enabling tracked actions. Call syncAnalyticsAccount(null) on confirmed logout. Preserve the same canonical IDs used by your other integrations.

**The last event is missing after force-quit.** The OS can stop JavaScript before a background flush completes. Compare a normal background/reopen test with a force-quit; do not promise lossless delivery from AppState or flush alone.

**Installation stops on a trust warning.** Keep the dependency safeguards enabled and investigate the flagged package. Do not treat a partial install as a completed native build.

## Frequently asked questions

### Do I need the Browser Unified SDK?

Use @amplitude/analytics-react-native for this native app. Its install and native-module requirements are in the React Native SDK reference.

### Does setUserId replace authentication?

No. The adapter consumes an account already verified by your application. The standalone example stays anonymous.

### Does this start session replay?

This page installs the analytics SDK and tracks the explicit report events. A replay integration is a separate setup.

### Does the UI test prove that events arrive?

The sample UI test mocks the analytics adapters and checks button wiring. Verify native operation and processed events in the app and Amplitude separately.

## Full code reference

Run the pinned initializer from step 1 first so Android, iOS and Bundler files exist. These are all complete custom files used by the tutorial, including both tooling files; no sibling tutorial supplies required code. Keep generated native files and merge additions carefully in an existing app.

**tooling/package.json**

tooling/package.json

```json
{
  "name": "react-native-build-checks",
  "private": true,
  "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
}
```

**tooling/pnpm-workspace.yaml**

tooling/pnpm-workspace.yaml

```yaml
minimumReleaseAge: 10080
trustPolicy: no-downgrade
```

**tooling/AmplitudeReports/package.json**

tooling/AmplitudeReports/package.json

```json
{
  "name": "AmplitudeReports",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "android": "react-native run-android",
    "ios": "react-native run-ios",
    "start": "react-native start",
    "test": "jest",
    "typecheck": "tsc --noEmit",
    "bundle:android": "react-native bundle --platform android --dev false --entry-file index.js --bundle-output artifacts/index.android.bundle --assets-dest artifacts/android"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-native": "0.88.0",
    "@react-native/new-app-screen": "0.88.0",
    "react-native-safe-area-context": "^5.5.2",
    "@amplitude/analytics-react-native": "1.12.3",
    "@react-native-async-storage/async-storage": "1.24.0"
  },
  "devDependencies": {
    "@babel/core": "^7.25.2",
    "@babel/preset-env": "^7.25.3",
    "@babel/runtime": "^7.25.0",
    "@react-native-community/cli": "20.2.0",
    "@react-native-community/cli-platform-android": "20.2.0",
    "@react-native-community/cli-platform-ios": "20.2.0",
    "@react-native/babel-preset": "0.88.0",
    "@react-native/jest-preset": "0.88.0",
    "@react-native/metro-config": "0.88.0",
    "@react-native/typescript-config": "0.88.0",
    "@types/jest": "^29.5.13",
    "@types/react": "^19.2.0",
    "@types/react-test-renderer": "^19.1.0",
    "jest": "^29.6.3",
    "react-test-renderer": "19.3.0",
    "typescript": "^6.0.3"
  },
  "engines": {
    "node": "^22.13.0 || ^24.3.0 || >=26.0.0"
  },
  "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
}
```

**tooling/AmplitudeReports/pnpm-workspace.yaml**

tooling/AmplitudeReports/pnpm-workspace.yaml

```yaml
minimumReleaseAge: 10080
trustPolicy: no-downgrade
nodeLinker: hoisted
overrides:
  "semver@6.3.1": "7.8.5"
```

**tooling/AmplitudeReports/app.json**

tooling/AmplitudeReports/app.json

```json
{
  "name": "AmplitudeReports",
  "displayName": "AmplitudeReports"
}
```

**tooling/AmplitudeReports/index.js**

tooling/AmplitudeReports/index.js

```javascript
/**
 * @format
 */

import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';

AppRegistry.registerComponent(appName, () => App);
```

**tooling/AmplitudeReports/babel.config.js**

tooling/AmplitudeReports/babel.config.js

```javascript
module.exports = {
  presets: ['module:@react-native/babel-preset'],
};
```

**tooling/AmplitudeReports/metro.config.js**

tooling/AmplitudeReports/metro.config.js

```javascript
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');

/**
 * Metro configuration
 * https://reactnative.dev/docs/metro
 *
 * @type {import('@react-native/metro-config').MetroConfig}
 */
const config = {};

module.exports = mergeConfig(getDefaultConfig(__dirname), config);
```

**tooling/AmplitudeReports/tsconfig.json**

tooling/AmplitudeReports/tsconfig.json

```json
{
  "extends": "@react-native/typescript-config",
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["**/node_modules", "**/Pods"]
}
```

**tooling/AmplitudeReports/jest.config.js**

tooling/AmplitudeReports/jest.config.js

```javascript
module.exports = {
  preset: '@react-native/jest-preset',
};
```

**tooling/AmplitudeReports/src/amplitude-config.ts**

tooling/AmplitudeReports/src/amplitude-config.ts

```typescript
// Project API keys identify a project; never put a secret management key here.
export const amplitudeConfig = {
  apiKey: '',
  serverZone: 'US' as 'US' | 'EU',
};
```

**tooling/AmplitudeReports/src/analytics.ts**

tooling/AmplitudeReports/src/analytics.ts

```typescript
// Keep any replay, experiment and engagement integrations when merging this file.
import * as amplitude from '@amplitude/analytics-react-native';
import {Platform} from 'react-native';
import {amplitudeConfig} from './amplitude-config';

let ready: Promise<boolean> | undefined;

export function startAnalytics(): Promise<boolean> {
  if (!ready) {
    ready = (async () => {
      if (!amplitudeConfig.apiKey.trim()) {
        return false;
      }
      await amplitude.init(amplitudeConfig.apiKey, undefined, {
        serverZone: amplitudeConfig.serverZone,
        autocapture: false,
      }).promise;
      return true;
    })().catch(() => {
      ready = undefined;
      return false;
    });
  }
  return ready;
}

async function reportEvent(name: string, days: number): Promise<void> {
  if (!(await startAnalytics())) {
    return;
  }
  // Queue in the background; tracking does not control whether the report renders.
  void amplitude.track(name, {
    report_type: 'usage',
    days,
    platform: Platform.OS,
  }).promise.catch(() => undefined);
}

export function reportPreviewed(days: number): Promise<void> {
  return reportEvent('Report Previewed', days);
}

export function reportRangeChanged(days: number): Promise<void> {
  return reportEvent('Report Range Changed', days);
}

export async function flushAnalytics(): Promise<void> {
  if (await startAnalytics()) {
    await amplitude.flush().promise;
  }

export function analyticsDeviceId(): string | undefined {
  return amplitude.getDeviceId();
}
```

**tooling/AmplitudeReports/src/account-analytics.ts**

tooling/AmplitudeReports/src/account-analytics.ts

```typescript
// Pass accounts from your existing verified authentication flow.
import * as amplitude from '@amplitude/analytics-react-native';
import {startAnalytics} from './analytics';

export type VerifiedAccount = {id: string; plan: string};

// Call from your app's existing verified sign-in/sign-out flow.
export async function syncAnalyticsAccount(account: VerifiedAccount | null): Promise<void> {
  if (!(await startAnalytics())) {
    return;
  }
  const previous = amplitude.getUserId();
  if (previous && previous !== account?.id) {
    amplitude.reset();
  }
  if (account) {
    amplitude.setUserId(account.id);
    void amplitude.identify(new amplitude.Identify().set('plan', account.plan)).promise.catch(() => undefined);
  }
```

**tooling/AmplitudeReports/App.tsx**

tooling/AmplitudeReports/App.tsx

```tsx
// For an existing app, merge the report and lifecycle handler into its current root.
import React, {useEffect, useState} from 'react';
import {AppState, Button, ScrollView, StyleSheet, Text, View} from 'react-native';
import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context';
import {
  analyticsDeviceId,
  flushAnalytics,
  reportPreviewed,
  reportRangeChanged,
  startAnalytics,
} from './src/analytics';
import {syncAnalyticsAccount} from './src/account-analytics';

export default function App() {
  const [days, setDays] = useState(7);
  const [opened, setOpened] = useState(false);
  const [status, setStatus] = useState('Starting analytics');
  const [reportReady, setReportReady] = useState(false);
  const [deviceId, setDeviceId] = useState<string>();

  useEffect(() => {
    let mounted = true;
    void startAnalytics().then(async enabled => {
      // The clean demo is anonymous. Existing apps pass their verified account here.
      await syncAnalyticsAccount(null);
      if (mounted) {
        setStatus(enabled ? 'Analytics initialized' : 'Analytics is off. Check your project key and SDK logs.');
        setReportReady(true);
        setDeviceId(analyticsDeviceId());
      }
    });
    const subscription = AppState.addEventListener('change', next => {
      if (next === 'background') {
        // Best effort only: the operating system can suspend JavaScript immediately.
        void flushAnalytics().catch(() => undefined);
      }
    });
    return () => {
      mounted = false;
      subscription.remove();
    };
  }, []);

  function openReport() {
    setOpened(true);
    void reportPreviewed(days);
  }

  function changeRange() {
    const nextDays = days === 7 ? 30 : 7;
    setDays(nextDays);
    void reportRangeChanged(nextDays);
  }

  return (
    <SafeAreaProvider>
      <SafeAreaView style={styles.screen}>
        <ScrollView contentContainerStyle={styles.content}>
          <Text style={styles.heading}>Usage report</Text>
          <Text>{status}</Text>
          <Button title="Open report" onPress={openReport} disabled={!reportReady} />
          {opened && (
            <View style={styles.report}>
              <Text>Last {days} days</Text>
              <Text>Demo activity: {days * 12} report actions</Text>
              <Button title="Change date range" onPress={changeRange} />
            </View>
          )}
          {__DEV__ && deviceId && <Text selectable>Device ID: {deviceId}</Text>}
        </ScrollView>
      </SafeAreaView>
    </SafeAreaProvider>
  );
}

const styles = StyleSheet.create({
  screen: {flex: 1, backgroundColor: '#FFFFFF'},
  content: {padding: 24, gap: 20},
  heading: {fontSize: 28, color: '#000000'},
  report: {gap: 12},
});
```

**tooling/AmplitudeReports/\_\_tests\_\_/App.test.tsx**

tooling/AmplitudeReports/\_\_tests\_\_/App.test.tsx

```tsx
import React from 'react';
import {Button} from 'react-native';
import renderer, {act} from 'react-test-renderer';
import App from '../App';
import {reportPreviewed, reportRangeChanged} from '../src/analytics';

jest.mock('../src/analytics', () => ({
  startAnalytics: jest.fn(async () => true),
  analyticsDeviceId: jest.fn(() => 'test-device-123'),
  reportPreviewed: jest.fn(async () => undefined),
  reportRangeChanged: jest.fn(async () => undefined),
  flushAnalytics: jest.fn(async () => undefined),
}));
jest.mock('../src/account-analytics', () => ({
  syncAnalyticsAccount: jest.fn(async () => undefined),
}));
jest.mock('react-native-safe-area-context', () => ({
  SafeAreaProvider: ({children}: {children: React.ReactNode}) => children,
  SafeAreaView: require('react-native').View,
}));

test('report actions call their analytics adapters', async () => {
  let app: renderer.ReactTestRenderer;
  await act(async () => {
    app = renderer.create(<App />);
  });
  await act(async () => {
    app!.root.findAllByType(Button).find(button => button.props.title === 'Open report')!.props.onPress();
  });
  expect(reportPreviewed).toHaveBeenCalledWith(7);
  await act(async () => {
    app!.root.findAllByType(Button).find(button => button.props.title === 'Change date range')!.props.onPress();
  });
  expect(reportRangeChanged).toHaveBeenCalledWith(30);
  await act(async () => app!.unmount());
});
```

## Hand it to your agent

**Agent prompt**

Agent prompt

```text
Follow https://amplitude.com/build/react-native-product-analytics. Start with the pinned React Native Community CLI setup and this page's complete custom files, including the semver dependency override; keep its generated native Android/iOS/Gemfile files. The CLI downloads its native template separately through npm; pnpm safeguards govern its CLI fetch and the later application install. Install @amplitude/analytics-react-native and AsyncStorage with pnpm, retain the package-manager integrity pin and dependency safeguards, install pods on iOS and rebuild native modules. Initialize once via the shared ready promise, choose the project's matching region, and keep autocapture false for these explicit report events. Preserve existing authentication, routes, replay, experiment and engagement integrations. The clean demo is anonymous; for an existing app, wait for its verified account and pass it to syncAnalyticsAccount before enabling tracked actions. Do not copy the demo's null account over a signed-in session. Preserve the first-login device, reset on confirmed logout/account switch, and keep canonical user IDs. Track Report Previewed and Report Range Changed with report_type, days and platform. AppState background flush is best effort. Test local button wiring and native builds separately from actual SDK uploads and processed profiles; report blocked native or live checks honestly. Never claim the mocked UI test proves ingestion or authentication.
```

## What this unlocks

[Product analytics](https://amplitude.com/amplitude-analytics) lets you compare report opens and date-range changes by the same user identity as the rest of your product.

[Feature flags](https://amplitude.com/feature-management) can add a targeted report variation when you integrate the Experiment SDK.

[Session replay](https://amplitude.com/session-replay) can provide mobile interaction context after its separate native SDK setup.

## Next steps

[FlutterProduct analyticsInstrument the report action in a Flutter app.](https://amplitude.com/build/flutter-product-analytics)[ReactProduct analyticsTrack the corresponding browser workflow.](https://amplitude.com/build/react-product-analytics)[Use caseStitch client and server eventsConnect mobile activity to backend work using canonical identity.](https://amplitude.com/build/stitch-client-server-events)

Reference docs

[Install a mobile SDK →](https://amplitude.com/docs/get-started/install-mobile-sdk)[React Native SDK →](https://amplitude.com/docs/sdks/analytics/react-native/react-native-sdk)[Debug analytics →](https://amplitude.com/docs/analytics/debug-analytics)[React Native native setup →](https://reactnative.dev/docs/set-up-your-environment)[AppState →](https://reactnative.dev/docs/appstate)

Amplitude is the leading [all-in-one analytics platform](https://amplitude.com/) for building better products. Product and engineering teams use Amplitude to connect [product analytics](https://amplitude.com/amplitude-analytics) and [web analytics](https://amplitude.com/web-analytics) with [session replay](https://amplitude.com/session-replay), [heatmaps](https://amplitude.com/heatmaps), [experimentation](https://amplitude.com/amplitude-experiment), [feature flags](https://amplitude.com/feature-management), and [guides and surveys](https://amplitude.com/guides-and-surveys) in one unified workflow, with [AI agents](https://amplitude.com/ai-agents) working across all of it. Learn how Amplitude is building [self-improving products](https://amplitude.com/self-improving-products) with [Wave](https://amplitude.com/wave), helping teams turn product data into PRs.

For agents

[View raw .md](/build/react-native-product-analytics.md)[llms.txt](/llms.txt)

## Ship the instrumentation, then read the data

Use the report events to see which users open the screen and change its date range.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
