React Native

How to Track User Behavior in React Native Apps

What to track in a React Native or Expo app, how to define screens and sessions, and how to build a track() wrapper without double counts or leaked PII.

On this page
  1. What behavior data tells you that crash logs and store stats don’t
  2. The events that matter
  3. Name events so they still make sense in a year
  4. Screen views and sessions on mobile
  5. What counts as a screen
  6. How sessions are usually defined
  7. Building it yourself in React Native
  8. A small track() wrapper
  9. Sessions with AppState
  10. Screen tracking with React Navigation
  11. Screen tracking with Expo Router
  12. Pitfalls that ruin the data
  13. Onboarding, paywalls and other key flows
  14. Anonymous users, signed-in users and sign-out
  15. Privacy and consent basics
  16. Doing this with Riveora

You shipped the app, people are installing it, and the store console says some of them come back. That’s about all it says. You don’t know which screens they actually use, where new users stop, which button they keep tapping that doesn’t work, or what the person who left a one-star review did before they gave up.

This article covers which events to record, how to define screens and sessions on mobile, how to build tracking yourself with React Navigation or Expo Router and a small track() function, and the mistakes that quietly ruin the data. The last section shows how Riveora does the same thing.

What behavior data tells you that crash logs and store stats don’t

App Store Connect and the Google Play Console tell you how many people installed, how many uninstalled, and what they rated you. A crash reporter tells you where the code broke. Both are useful, and neither tells you what people did between opening the app and closing it.

Behavior data answers a different set of questions:

  • Where do new users stop? If people install and never return, the reason is usually in their first visit: a sign-up form, a permissions screen, an onboarding step.
  • Which screens get used? The feature you spent a month on might barely be opened.
  • What frustrates people without crashing? A button that spins for six seconds, a request that fails silently, an image people tap because it looks like a link. None of these appear in a crash log.
  • Did the release change anything? Do fewer people leave the checkout screen in 1.4 than in 1.3?
  • What did this person do? When someone writes to support, a timeline of their last visit beats any chart.

You don’t need hundreds of events for this, just a few kinds, recorded consistently, with enough context to slice the data later.

The events that matter

Useful mobile tracking fits into five groups. The first four are generic; the fifth is specific to your product.

KindExample eventsWhat it answers
Screensscreen_viewedWhich screens people reach, in what order, and how long they stay
Sessionssession_started, app_backgroundedHow often people open the app, how long a visit lasts, where visits end
Tapselement_tappedWhat people try to do on a screen, and what they ignore
Errorsapp_error, request_failedWhich problems real people hit, and whether they leave right after
Key actionsrecipe_saved, workout_completedWhether people get the value your app exists to give them

Every event should also carry the same context: a unique event ID, the time it happened on the device, an anonymous user ID, your own user ID if they’re signed in, the session it belongs to, the screen that was open, the platform and your app version. The app version is the one people forget, and the one that answers “did the release help?”

For a fuller list of product events, with examples for different kinds of apps, see Essential Analytics Events Every Mobile App Should Track.

Name events so they still make sense in a year

Three months in, you’ll find saveRecipe, recipe_saved and Recipe Saved in the same project, and no chart will add up. Pick a convention before the first event ships:

  • object_action, in snake_case, past tense. recipe_saved, list_shared, workout_completed. The object first means related events sort together.
  • Variations go in properties, not names. recipe_saved with { source: 'search' }, not recipe_saved_from_search.
  • Never rename. If an event’s meaning changes, give it a new name and stop sending the old one. Renaming splits your history in two.
  • Write it down. A short list of names, when each fires and its properties, kept in the repo.

Screen views and sessions on mobile

Screens and sessions sound simple and both hide decisions that change your numbers. Make them on purpose.

What counts as a screen

Name screens after the route, not the address. /recipe/8812 and /recipe/9031 are the same screen; record the address and you get thousands of “screens” with one view each, plus IDs you didn’t mean to store. With Expo Router, use the route file (/recipe/[id]). With React Navigation, use the route name (RecipeDetail).

The opposite problem also exists: bottom sheets, in-screen tabs and modals drawn inside a screen don’t change the route, so a navigation listener never sees them. If people spend real time in a sheet, record it as a screen yourself.

Time on screen has its own traps (backgrounding, modals covering the screen), covered in How to Measure Screen Engagement in React Native.

How sessions are usually defined

On the web, a session traditionally ends after 30 minutes without activity. That fits phones badly: people open an app for forty seconds and lock the phone, or switch to their bank app to approve a payment and come straight back.

Mobile sessions are usually tied to the foreground instead: a session starts when the app is launched, or when it comes back after being away longer than some threshold. Shorter absences continue the same session. The threshold is a product decision. A minute or two treats each time someone picks up their phone as a new visit; a long one merges separate uses into one.

Two platform details matter here:

  • iOS reports inactive as well as background. React Native’s AppState reports inactive during transitions and when something covers the app briefly, such as Notification Center, an incoming call or the app switcher. Treat only background as leaving, or every permission prompt splits a session.
  • Apps can start without anyone opening them. A background fetch or a silent push can launch your JavaScript while the app is in the background. If you start a session on launch unconditionally, these count as visits nobody made.

Building it yourself in React Native

The pieces are a track() function with a queue, a navigation listener for screens, and an AppState listener for sessions. The code sends events to an endpoint of your own; swap in whatever your backend or analytics tool accepts.

A small track() wrapper

Don’t call an analytics SDK directly from components. Put one module in between, so naming, cleaning and sending live in one place and switching tools later touches one file.

analytics.tsts
import AsyncStorage from '@react-native-async-storage/async-storage';

type PropValue = string | number | boolean | null;
type Props = Record<string, PropValue>;

interface AnalyticsEvent {
  id: string;
  name: string;
  properties: Props;
  screen: string | null;
  sessionId: string;
  occurredAt: string;
}

const ENDPOINT = 'https://api.example.com/events';
const QUEUE_KEY = 'analytics.queue';
const MAX_QUEUE = 500; // oldest events are dropped first
const FLUSH_AT = 20;
const BATCH_SIZE = 50;

// Keys that suggest personal data or secrets never leave the device.
const BLOCKED_KEY = /email|phone|password|token|address/i;

let queue: AnalyticsEvent[] = [];
let currentScreen: string | null = null;
let sessionId = randomId();
let sending = false;
let loaded = false;

function randomId(): string {
  return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
}

function clean(properties: Props): Props {
  return Object.fromEntries(Object.entries(properties).filter(([key]) => !BLOCKED_KEY.test(key)));
}

function save(): void {
  if (!loaded) return; // don't overwrite the saved queue before it has been read
  void AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}

export function setScreen(name: string): void {
  currentScreen = name;
}

export function track(name: string, properties: Props = {}): void {
  queue.push({
    id: randomId(),
    name,
    properties: clean(properties),
    screen: currentScreen,
    sessionId,
    occurredAt: new Date().toISOString(),
  });
  if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE);
  save();
  if (queue.length >= FLUSH_AT) void flush();
}

export async function flush(): Promise<void> {
  if (sending || queue.length === 0) return;
  sending = true;
  const batch = queue.slice(0, BATCH_SIZE);
  try {
    const res = await fetch(ENDPOINT, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ events: batch }),
    });
    if (res.ok) {
      const sent = new Set(batch.map((e) => e.id));
      queue = queue.filter((e) => !sent.has(e.id));
      save();
    }
  } catch {
    // Offline or the server is down: keep the events and try again on the next flush.
  } finally {
    sending = false;
  }
}

async function loadSavedQueue(): Promise<void> {
  const saved = await AsyncStorage.getItem(QUEUE_KEY);
  if (saved) queue = [...(JSON.parse(saved) as AnalyticsEvent[]), ...queue];
  loaded = true;
  save();
}

A few choices in there are deliberate:

  • Events are batched. A request per tap drains the battery and fails on a train. Sending every 20 events or every few seconds, whichever comes first, is a reasonable default.
  • The queue is saved to storage, so events recorded offline or just before the app was killed are sent on the next launch. It’s capped, so a phone offline for a week doesn’t fill up.
  • Every event has its own ID. A request that timed out after the server stored it gets retried, so your backend should ignore IDs it has already seen.
  • The time is recorded when the event happens, not when it’s sent: a batch can arrive hours later.

Sessions with AppState

Add sessions to the same module. This starts a new session after more than two minutes away, ignores inactive, flushes on background (the system may kill the app next), and waits if the app was launched in the background.

analytics.ts (continued)ts
import { AppState, type AppStateStatus } from 'react-native';

const BACKGROUND_TIMEOUT_MS = 2 * 60 * 1000;
const FLUSH_EVERY_MS = 10_000;

let started = false;
let sessionRunning = false;
let leftAt: number | null = null;

function startSession(reason: 'launch' | 'returned'): void {
  sessionId = randomId();
  sessionRunning = true;
  track('session_started', { reason });
}

function onAppStateChange(state: AppStateStatus): void {
  if (state === 'background') {
    if (leftAt === null && sessionRunning) {
      leftAt = Date.now();
      track('app_backgrounded');
    }
    void flush();
  } else if (state === 'active') {
    if (!sessionRunning) {
      startSession('launch'); // launched in the background, opened now
    } else if (leftAt !== null) {
      const awayMs = Date.now() - leftAt;
      if (awayMs > BACKGROUND_TIMEOUT_MS) startSession('returned');
      else track('app_foregrounded', { away_ms: awayMs });
    }
    leftAt = null;
  }
  // 'inactive' (iOS): something covers the app for a moment. Not leaving.
}

export function startAnalytics(): void {
  if (started) return; // React StrictMode runs effects twice in development
  started = true;
  void loadSavedQueue();
  if (AppState.currentState !== 'background') startSession('launch');
  AppState.addEventListener('change', onAppStateChange);
  setInterval(() => void flush(), FLUSH_EVERY_MS);
}

Screen tracking with React Navigation

React Navigation’s screen tracking guide uses the container’s onReady and onStateChange callbacks and compares the current route name with the last one it saw. That comparison is what stops double counting: onStateChange also fires when only a route’s params change.

App.tsxtsx
import { useEffect, useRef } from 'react';
import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native';
import { setScreen, startAnalytics, track } from './analytics';

export default function App() {
  const navigationRef = useNavigationContainerRef();
  const lastScreen = useRef<string | null>(null);

  useEffect(() => {
    startAnalytics();
  }, []);

  const onNavigation = () => {
    const name = navigationRef.current?.getCurrentRoute()?.name;
    if (!name || name === lastScreen.current) return; // same screen, new params
    const previous = lastScreen.current;
    lastScreen.current = name;
    setScreen(name);
    track('screen_viewed', { previous_screen: previous });
  };

  return (
    <NavigationContainer ref={navigationRef} onReady={onNavigation} onStateChange={onNavigation}>
      {/* your navigators */}
    </NavigationContainer>
  );
}

getCurrentRoute() returns the focused route of the deepest nested navigator, so a tab inside a stack is reported as the tab’s screen.

Screen tracking with Expo Router

Expo’s screen tracking page uses usePathname(), which gives you the address: /recipe/8812. To get the route file instead, use useSegments(), which returns the segments as they appear in the file path, dynamic parts included as [id]. Drop the route groups in parentheses, which aren’t part of the address.

app/_layout.tsxtsx
import { useEffect, useRef } from 'react';
import { Stack, useSegments } from 'expo-router';
import { setScreen, startAnalytics, track } from '../analytics';

function useScreenTracking() {
  const segments = useSegments();
  // ['(tabs)', 'recipe', '[id]'] becomes '/recipe/[id]'
  const screen = '/' + segments.filter((s) => !s.startsWith('(')).join('/');
  const lastScreen = useRef<string | null>(null);

  useEffect(() => {
    if (screen === lastScreen.current) return;
    const previous = lastScreen.current;
    lastScreen.current = screen;
    setScreen(screen);
    track('screen_viewed', { previous_screen: previous });
  }, [screen]);
}

export default function RootLayout() {
  useEffect(() => {
    startAnalytics();
  }, []);
  useScreenTracking();

  return <Stack />;
}

Effects run in the order they’re declared, so startAnalytics() runs before the first screen_viewed.

Pitfalls that ruin the data

  • Double firing. Tracking in a component body records an event on every render. Tracking in a screen’s useEffect and in a navigation listener records every view twice. StrictMode runs effects twice in development, so guard one-time setup. Pick one place per event.
  • Tracking in onPress before the action succeeds. Send recipe_saved after the save request returns, not when the button is pressed. Otherwise failures count as successes.
  • Personal data in properties. It slips in through search queries, free text, names in screen addresses and error messages. Send IDs and categories, never what people typed. A key filter catches some mistakes, not all.
  • Unbounded property values. A property like { query: text } creates one value per person and turns a chart into noise. Prefer a fixed set of values ({ source: 'search' }) or a number ({ result_count: 12 }).

Onboarding, paywalls and other key flows

For the flows your business depends on, add explicit markers, because a flow isn’t a set of screens. An onboarding might be four routes or one route with a pager, and the same screen can appear in onboarding and in settings.

The pattern is the same for any flow: mark the start, each step as it’s shown, and the end.

onboarding.tsts
track('onboarding_started', { flow: 'new_user_v2' });
track('onboarding_step_viewed', { flow: 'new_user_v2', step: 'pick_goal', step_index: 0 });
track('onboarding_step_viewed', { flow: 'new_user_v2', step: 'notifications', step_index: 1 });
track('onboarding_completed', { flow: 'new_user_v2' });

From these you get a funnel: say 1,000 people start, 820 see the second step, 410 see the third and 380 finish. Look at the big drop between the second and third step, and at the screens and taps in those visits. Version the flow name when you change the steps, so old and new funnels don’t mix.

Two companion articles go deeper: How to Track Onboarding Drop-Off in Mobile Apps and How to Track Paywall Views and Engagement in Mobile Apps.

Anonymous users, signed-in users and sign-out

Most apps let people look around before signing up, so every install needs an anonymous ID from the first launch: a random value created on the device and saved to storage. Don’t use a hardware or advertising identifier; a random ID tells installs apart and means nothing outside your app.

When someone signs in, attach your own user ID to every event from then on. A few rules:

  • Use your internal database ID, never an email address or phone number. The ID lets you look someone up when they contact support; the email is personal data your analytics doesn’t need.
  • Keep the anonymous history. Visits before sign-up are often the most interesting ones, so keep the anonymous ID linked to the signed-in user.
  • Start fresh on sign-out. Create a new anonymous ID and a new session, so the next person who signs in on a shared device isn’t merged with the previous one.
  • Use the same ID everywhere. If purchases are tracked by a subscription service, give it the same user ID so a purchase can be joined to the behavior that led to it.

Behavior tracking doesn’t need personal data. Knowing that someone tapped “Save” on /recipe/[id] is useful; knowing their name isn’t.

  • Never record what people type. Not search queries, not form fields, not chat messages.
  • Mask IDs in screen names, network paths and error messages before they leave the device.
  • Gate tracking on consent where required. Your track() checks a consent flag, records nothing until it’s granted, and deletes the queue if consent is refused.
  • Declare it in the stores. Apple’s App Privacy Details lists taps and other product interaction under usage data, including data collected by third-party analytics tools. Google Play asks the same in its Data safety section, where app interactions and crash logs are separate data types. Your privacy policy should say the same.

This is general guidance, not legal advice. Whether you need opt-in consent depends on where your users are and what you collect, so check the rules that apply to your app.

Doing this with Riveora

Riveora replaces the wrapper, navigation listener and AppState handling above with a provider and a tracker for your navigation library. With Expo Router:

app/_layout.tsxtsx
import { Stack } from 'expo-router';
import Constants from 'expo-constants';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { RiveoraProvider, AsyncStorageAdapter } from '@riveora/react-native';
import { ExpoRouterTracker } from '@riveora/react-native/expo-router';

const storage = new AsyncStorageAdapter(AsyncStorage);

export default function RootLayout() {
  return (
    <RiveoraProvider
      projectKey="pk_your_project_key"
      storage={storage}
      appVersion={Constants.expoConfig?.version}
      consent="granted"
    >
      <ExpoRouterTracker />
      <Stack />
    </RiveoraProvider>
  );
}

React Navigation works the same way with ReactNavigationTracker and your container ref; see the Expo Router and React Navigation guides. From there, without more code:

  • Screens are named after their route files, so /product/8812 shows up as /product/:id, and time on screen is recorded when someone leaves it. For sheets and in-screen tabs, call Riveora.screen('ProductSheet').
  • Taps are recorded with a label (the accessibility label or a button’s short text, never what people typed), the element type and the position. Rage taps and taps on things that don’t respond are flagged. See Automatic capture.
  • JavaScript crashes and failed or slow network requests are captured, with IDs masked out of the paths and no query strings, headers or bodies.
  • Visits start when the app is opened or someone comes back after more than 2 minutes away. iOS system prompts don’t count as leaving, and background launches don’t start one. See Visits to change the limit.
  • Events are queued on the device and sent every 10 seconds or 20 events, so events recorded offline are sent later.

For the parts only your app knows about, there are a few calls:

ts
import { Riveora } from '@riveora/react-native';

// After sign-in: your internal user id, never an email or phone number
await Riveora.identify(user.id);

// On sign-out: a new anonymous profile for the next person on this device
await Riveora.reset();

// Actions that matter to your product
Riveora.track('recipe_saved', { recipe_id: 'r_42', source: 'search' });

// Onboarding steps
Riveora.onboarding.start('main');
Riveora.onboarding.stepViewed('main', 'goals', 0);
Riveora.onboarding.complete('main');

identify() refuses emails and phone numbers, and properties that look like personal data or secrets are dropped on the device and again on the server (see Identifying users and Custom events). Nothing is sent until consent is "granted"; if you ask users first, start with consent="unknown" and call Riveora.setConsent() with their answer (see Privacy & consent).

In the dashboard, that data shows up as:

  • Overview: active users, visits, screen views, taps, rage taps, crashes, onboarding completions and paywall views.
  • Screens: per screen, how many people reached it, typical time there, taps per visit, how often people leave the app from it, rage taps, a tap map, and where people came from and went next.
  • Users: each person’s visits, replayed screen by screen with their taps, errors and custom events.
  • Why people leave: problems people ran into, such as crashes, failed requests and rage taps, ranked by the sales they cost, and the screens where visits most often end.
  • Onboarding: how many people start and finish, and how many reached each step.
  • Live events: events as they arrive, which is the quickest way to check a new track() call.

The Quickstart walks through the setup, and the event catalog lists every event the SDK sends.

All articles
Track User Behavior in React Native Apps · Riveora