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 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 before building.
  • An Amplitude project API key from Organization settings > API 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 to get your project API key.

1. Create the native app and install dependencies

I start with the React Native Community CLI so the project includes native Android and iOS files. The mobile installation guide supplies the Amplitude SDK and AsyncStorage dependency. A fresh project does not need another Build tutorial.

Terminal, new project

Terminal, new project
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
{
  "name": "react-native-build-checks",
  "private": true,
  "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
}

tooling/pnpm-workspace.yaml

tooling/pnpm-workspace.yaml
minimumReleaseAge: 10080
trustPolicy: no-downgrade

Terminal, create the app

Terminal, create the app
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
{
  "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
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
{
  "name": "AmplitudeReports",
  "displayName": "AmplitudeReports"
}

index.js

index.js
/**
 * @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
module.exports = {
  presets: ['module:@react-native/babel-preset'],
};

metro.config.js

metro.config.js
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
{
  "extends": "@react-native/typescript-config",
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["**/node_modules", "**/Pods"]
}

jest.config.js

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

Terminal, install

Terminal, install
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.

src/amplitude-config.ts

src/amplitude-config.ts
// 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
// 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
// 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
// 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
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
pnpm start
# In a second terminal inside AmplitudeReports:
pnpm android

Terminal, iOS on macOS

Terminal, iOS on macOS
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 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
{
  "name": "react-native-build-checks",
  "private": true,
  "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8"
}

tooling/pnpm-workspace.yaml

tooling/pnpm-workspace.yaml
minimumReleaseAge: 10080
trustPolicy: no-downgrade

tooling/AmplitudeReports/package.json

tooling/AmplitudeReports/package.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
minimumReleaseAge: 10080
trustPolicy: no-downgrade
nodeLinker: hoisted
overrides:
  "semver@6.3.1": "7.8.5"

tooling/AmplitudeReports/app.json

tooling/AmplitudeReports/app.json
{
  "name": "AmplitudeReports",
  "displayName": "AmplitudeReports"
}

tooling/AmplitudeReports/index.js

tooling/AmplitudeReports/index.js
/**
 * @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
module.exports = {
  presets: ['module:@react-native/babel-preset'],
};

tooling/AmplitudeReports/metro.config.js

tooling/AmplitudeReports/metro.config.js
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
{
  "extends": "@react-native/typescript-config",
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["**/node_modules", "**/Pods"]
}

tooling/AmplitudeReports/jest.config.js

tooling/AmplitudeReports/jest.config.js
module.exports = {
  preset: '@react-native/jest-preset',
};

tooling/AmplitudeReports/src/amplitude-config.ts

tooling/AmplitudeReports/src/amplitude-config.ts
// 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
// 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
// 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
// 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
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
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 lets you compare report opens and date-range changes by the same user identity as the rest of your product.

Feature flags can add a targeted report variation when you integrate the Experiment SDK.

Session replay can provide mobile interaction context after its separate native SDK setup.

Next steps

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

Ship the instrumentation, then read the data

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