React Native

How to Measure Screen Engagement in React Native

Track screen views and real time on screen in React Native: Expo Router, React Navigation, AppState pauses, session timeouts and reading the numbers.

On this page
  1. What “time on screen” should mean
  2. Recording screen views
  3. React Navigation
  4. Expo Router
  5. Name screens by pattern, not by address
  6. Nested navigators, modals and bottom sheets
  7. Pausing the timer when the app leaves the foreground
  8. When a pause becomes a new session
  9. A small timer that handles navigation and AppState together
  10. Navigation cases worth testing by hand
  11. Cleaning up durations before you summarize them
  12. Reading screen engagement without fooling yourself
  13. Doing this with Riveora
  14. What gets recorded
  15. Screens that aren’t routes
  16. What the dashboard shows

Counting screen views in a React Native app takes about ten lines of code. Measuring how long each screen was actually in front of someone is harder. The app goes to the background, iOS covers it with a permission prompt, a tab switch doesn’t look like a navigation, and the last screen of every session never gets a “next screen” event to close it. If you skip those cases, your “time on screen” ends up including the twenty minutes the phone sat locked on the kitchen counter.

This article walks through the whole job: recording screen views with React Navigation and Expo Router, timing each visit correctly, pausing when the app leaves the foreground, handling the awkward navigation cases, and reading the numbers without fooling yourself. There’s a small typed timer you can copy, and at the end, a look at how Riveora does the same thing.

What “time on screen” should mean

Before writing code, pin down the definition, because every later decision follows from it. A useful one: the time a screen was focused while the app was in the foreground. Not the time between two screen-view events, and not the time the component was mounted.

Both of those shortcuts are tempting and both are wrong:

  • Subtracting consecutive view timestamps gives no duration for the last screen of a session (there’s no next view), and any time spent in another app lands on whichever screen was open when the person left.
  • Mount to unmount doesn’t work in a stack: the screen underneath stays mounted while you push three more on top. Tabs stay mounted too once they’ve been visited.

So you need two events per visit: screen_viewed when the screen gains focus and screen_left when it loses it, carrying the measured duration. Measure on the device: only it knows when the app was in the background.

Recording screen views

The first piece is knowing which screen is focused at any moment. Both major routers give you that, in slightly different ways.

React Navigation

React Navigation’s own screen tracking guide uses a ref on the navigation container plus its onReady and onStateChange callbacks. getCurrentRoute() returns the deepest focused route, so nested stacks inside tabs inside a drawer resolve to the screen the person is actually looking at:

App.tsxtsx
import { useState } from 'react';
import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native';
import { RootStack } from './src/RootStack';

export default function App() {
  const navigationRef = useNavigationContainerRef();
  const [screen, setScreen] = useState<string | null>(null);

  // The deepest focused route, across nested navigators.
  const syncScreen = () => setScreen(navigationRef.getCurrentRoute()?.name ?? null);

  return (
    <NavigationContainer ref={navigationRef} onReady={syncScreen} onStateChange={syncScreen}>
      <RootStack />
    </NavigationContainer>
  );
}

onReady matters: the initial screen doesn’t trigger onStateChange, so without it the first screen of every launch goes missing. Keeping the name in React state also gives you deduplication for free, because setting the same string again doesn’t re-render.

Expo Router

With Expo Router you read the route from hooks in the root layout. Expo’s screen tracking page uses usePathname(), which returns the address, such as /recipe/8812. For analytics, useSegments() is usually the better source, because it returns the route file’s segments, such as ['recipe', '[id]']:

app/_layout.tsxtsx
import { Stack, useSegments } from 'expo-router';

export default function RootLayout() {
  const segments: readonly string[] = useSegments();
  // ['(tabs)', 'recipe', '[id]'] -> '/recipe/[id]'. Groups aren't part of the URL, so drop them.
  const screen = '/' + segments.filter((s) => !s.startsWith('(')).join('/');

  // ...pass `screen` to your tracker (see the timer below)
  return <Stack />;
}

Name screens by pattern, not by address

The name you send should identify the screen, not the content on it. /recipe/8812 and /recipe/9031 are the same screen. Raw paths turn your screens report into thousands of rows with one visit each, and they sometimes contain things you shouldn’t send at all, like a username in a profile URL. Route names and file patterns avoid both problems. If you need the ID, send it as a separate property.

Nested navigators, modals and bottom sheets

Nested navigators are handled by getCurrentRoute() and by segments. A modal that is a route (a stack screen with presentation: 'modal', or a modal route file in Expo Router) is handled too.

The gap is UI that changes what people see without changing the route: bottom sheets, in-screen tab views, a modal drawn with React Native’s Modal component. If one matters on its own, record it explicitly when it opens and closes, and decide up front whether its time also counts towards the screen behind it.

Pausing the timer when the app leaves the foreground

React Native’s AppState tells you whether the app is in front. It reports these states:

StatePlatformWhat it means
activeBothThe app is in the foreground.
backgroundBothAnother app, the home screen, or the phone is locked. On Android, also another Activity, including system screens your app opened.
inactiveiOS onlyIn transition between foreground and background, or covered for a moment: the app switcher, Notification Center, an incoming call.

The rule that works in practice: count time only while the state is active. Pause on anything else, resume on active.

iOS’s inactive is the one that catches people out. It fires when someone swipes up into the app switcher (and might close your app from there) and when a system sheet covers you. Pausing the timer there is right: the person is looking at something that isn’t your screen. But inactive is not “left the app”. Treating it as backgrounding produces sessions split in half by every permission prompt.

Android has no inactive. Pulling down the notification shade leaves the state at active and fires a separate blur event, which most apps can ignore. Some Android system screens do send your app to background briefly, which is one more reason not to end a session the moment the app goes to the background.

When a pause becomes a new session

A quick trip to Messages to copy a code is part of the same task. Coming back three hours later is not. Pick a timeout (somewhere between one and thirty minutes, depending on how your app is used) and when the app returns after longer than that:

  1. Close the open screen visit with the time counted so far. It belongs to the old session.
  2. Start a new session.
  3. Record the same screen again as the first screen of the new session.

Step 3 is easy to miss. Without it, the new session has no screen until the person navigates, and your “first screen of the session” numbers ignore everyone who reopened the app where they left it.

A small timer that handles navigation and AppState together

Here’s a self-contained timer that does all of the above. It takes a track function from whatever analytics client you use, gets told when the focused screen changes, and listens to AppState itself:

src/screen-timer.tsts
import { AppState, type AppStateStatus } from 'react-native';

export type Props = Record<string, string | number | boolean>;
export type Track = (event: string, props: Props) => void;

/** Back after longer than this starts a new session. */
const SESSION_TIMEOUT_MS = 2 * 60 * 1000;

type EndReason = 'navigated' | 'session_timeout' | 'unmounted';

export class ScreenTimer {
  private screen: string | null = null;
  private visibleMs = 0;
  /** Start of the current stretch of visible time; null while paused. */
  private activeSince: number | null = null;
  /** When the app went to the background; null while it's in front. */
  private backgroundAt: number | null = null;
  private appState: AppStateStatus = AppState.currentState;
  private readonly subscription: { remove(): void };

  constructor(private readonly track: Track) {
    this.subscription = AppState.addEventListener('change', this.onAppStateChange);
  }

  /** Call whenever the focused screen changes. The same name again does nothing. */
  setScreen(screen: string | null, now = Date.now()): void {
    if (screen === this.screen) return;
    this.end('navigated', now);
    if (screen) this.start(screen, now);
  }

  dispose(now = Date.now()): void {
    this.end('unmounted', now);
    this.subscription.remove();
  }

  private start(screen: string, now: number): void {
    this.screen = screen;
    this.visibleMs = 0;
    // Opened while the app isn't in front (e.g. from a notification): wait for 'active'.
    this.activeSince = this.appState === 'active' ? now : null;
    this.track('screen_viewed', { screen });
  }

  private end(reason: EndReason, now: number): void {
    if (!this.screen) return;
    if (this.activeSince !== null) this.visibleMs += now - this.activeSince;
    this.track('screen_left', { screen: this.screen, visible_ms: Math.round(this.visibleMs), reason });
    this.screen = null;
    this.activeSince = null;
  }

  private onAppStateChange = (next: AppStateStatus): void => {
    const now = Date.now();
    this.appState = next;

    if (next === 'active') {
      const awayMs = this.backgroundAt === null ? 0 : now - this.backgroundAt;
      this.backgroundAt = null;
      if (this.screen && awayMs > SESSION_TIMEOUT_MS) {
        // The old visit ends with the time it already had (the timer is paused),
        // then the same screen opens the new session.
        const screen = this.screen;
        this.end('session_timeout', now);
        this.track('session_started', { away_ms: awayMs });
        this.start(screen, now);
      } else if (this.screen && this.activeSince === null) {
        this.activeSince = now;
      }
      return;
    }

    // 'inactive' (iOS) or 'background': stop counting.
    if (this.activeSince !== null) {
      this.visibleMs += now - this.activeSince;
      this.activeSince = null;
    }
    if (next === 'background' && this.backgroundAt === null) this.backgroundAt = now;
  };
}

And a hook that ties its lifetime to a component:

src/useScreenTimer.tsts
import { useEffect, useState } from 'react';
import { ScreenTimer, type Track } from './screen-timer';

/** Pass a stable `track` (defined outside the component), or the timer restarts. */
export function useScreenTimer(screen: string | null, track: Track): void {
  const [timer, setTimer] = useState<ScreenTimer | null>(null);

  useEffect(() => {
    const t = new ScreenTimer(track);
    setTimer(t);
    return () => t.dispose();
  }, [track]);

  useEffect(() => {
    timer?.setScreen(screen);
  }, [timer, screen]);
}

Call useScreenTimer(screen, track) in the App component or root layout from the earlier examples. The timer is created inside an effect rather than during render, so Strict Mode’s double mounting in development doesn’t leave a stray AppState listener behind.

What this sketch leaves out

A production version would also give each visit an ID so the two events can be joined reliably, and backdate the closing event of a timed-out visit to when the app was hidden.

Run through these on a real device with your event log open.

  • Same screen, new params. Going from one recipe to a related recipe keeps the screen name the same. With the code above, that’s one continuous visit. If you’d rather count each recipe separately, key the visit on React Navigation’s route.key (or on the pathname in Expo Router) while still sending the pattern as the name.
  • Tab switches. Tabs stay mounted, so anything based on mount and unmount misses them. Focus tracking through the container or segments handles them correctly. Check that re-tapping the current tab doesn’t log a new view.
  • Going back. The screen you return to should get a new visit with a fresh timer, not resume the old one.
  • Deep links and notifications. The app can open straight onto a detail screen, sometimes before AppState is active. Make sure that first screen is recorded and its timer starts when the app actually comes to the front.
  • Fast double navigation. A double tap pushes the same screen twice; an auth check redirects from the home screen to sign-in within a frame. You get visits of a few milliseconds. Keep recording them, but filter very short visits out of duration statistics.
  • The first screen after launch. Confirm it appears once, not zero times (missed onReady) and not twice (both onReady and a state change firing for it).
  • Launched in the background. A background fetch or silent push can start your JavaScript with nobody looking. No time should accrue until the app is active.

Cleaning up durations before you summarize them

Screen durations have a long tail. Pausing on background removes the worst of it, but not a phone left unlocked face up on a desk.

  • Use the median, not the mean. Say a recipe screen has 99 visits of about 40 seconds and one of 50 minutes. The median stays at 40 seconds; the mean jumps to about 70. The median is what a typical visit looks like, and it doesn’t move when one person falls asleep.
  • Cap or drop extreme values. If you do need sums or averages (total time in app, for example), clamp each visit to a ceiling that makes sense for your app. Do it when you query, not on the device, so you can change your mind later without losing data.
  • Mark incomplete visits. A visit closed by a session timeout or by the app being killed didn’t end with a decision to leave the screen. Keep a reason on the closing event so you can separate “moved on” from “put the phone down”.

Reading screen engagement without fooling yourself

A duration on its own isn’t good or bad. Long time on a feed or a recipe’s cooking steps means people are getting value. Long time on a checkout or a sign-up form usually means they’re stuck. Decide for each important screen whether you want it fast or long before you look.

Then put the duration next to the signals that explain it:

  • Exits. What share of visits were the last screen before the person left the app? A short visit followed by moving on is efficient. A short visit followed by leaving the app is a bounce.
  • Taps. A form screen with long durations and few taps suggests confusion: people are reading, not doing. A screen with lots of taps on something that has no tap handler tells you people expect it to do something.
  • Rage taps. Quick repeated taps on the same spot are the clearest sign of a button that’s slow, broken or gives no feedback, and they usually explain a long duration better than any chart.
  • App version. Tag every event with the app version and compare before and after a change. A redesign that cuts median checkout time from 90 to 50 seconds is a result; the same drop spread across a month of mixed versions is noise.

For which other events make these comparisons possible, see Essential Analytics Events Every Mobile App Should Track, and for following individual people through a session, How to Track User Behavior in React Native Apps.

Doing this with Riveora

Riveora’s React Native SDK does everything described above for you. You mount a tracker for your router inside RiveoraProvider; with Expo Router it’s ExpoRouterTracker, from its own entry point:

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>
  );
}

Screens are named after their route files, so /product/8812 is recorded as /product/:id. An address no route file matches is masked the same way. With React Navigation, you pass your container ref to ReactNavigationTracker instead, and the screen is the deepest focused route; it also copes with a container that mounts later, for example after sign-in. The Expo Router and React Navigation guides cover the options, such as renaming or skipping screens.

What gets recorded

Each screen visit produces a screen_viewed event and, when it ends, a screen_visibility_ended event with foreground_visible_ms (the time the screen was on display with the app in front), the number of taps and swipes during the visit, and why it ended. The timer pauses whenever AppState isn’t active, so iOS’s inactive moments don’t count as time on screen. The full field list is in the event catalog.

Visits (Riveora’s sessions) follow the rules from earlier: a launch starts a new visit, and so does coming back after more than 2 minutes away. You can change that with the backgroundTimeoutMs prop. When that happens, the open screen ends in the old visit, marked incomplete, and is shown again as the first screen of the new one. A system prompt on iPhone doesn’t end a visit, and an app woken in the background doesn’t start one until someone opens it.

Screens that aren’t routes

For a bottom sheet, an in-screen tab or a modal drawn inside a screen, call Riveora.screen() when it opens:

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

const openFilters = () => {
  setFiltersOpen(true);
  Riveora.screen('FiltersSheet');
};

That records a view, and events after it are tagged with the sheet until the next screen change. It doesn’t run a timer of its own: the time is still counted towards the route underneath. If you use a navigation library other than Expo Router or React Navigation, the trackers’ building block is exported too: useScreenVisibility(screen) records the view and the visible time for whatever screen name you pass. See the SDK reference.

What the dashboard shows

The Screens page lists every screen with the number of people who opened it in the last 7 days, total visits and when it was last seen. Development builds also send the app’s full list of screens, so screens nobody has opened yet appear, marked “Not visited yet”.

Opening a screen shows the last 30 days of how people use it:

  • Typical time here: the median of the time the screen was actually on display, so one forgotten phone doesn’t skew it.
  • Taps per visit, with scrolls and swipes, and Leave the app here: the share of visits where this was the last screen.
  • Rage taps, and a “Worth fixing” list that calls out elements people tap that don’t respond, rage taps and crashes on that screen.
  • A tap map, where people came from and went next, and recent visitors with their time on the screen.

Each person’s Journey replays a visit screen by screen with how long each one was visible, which is often the quickest way to understand an odd median. How taps and rage taps are detected is described in Automatic capture. If a screen you care about is a paywall, there’s a separate guide on tracking paywall views and engagement.

All articles
Measure Screen Engagement in React Native · Riveora