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
- What “time on screen” should mean
- Recording screen views
- React Navigation
- Expo Router
- Name screens by pattern, not by address
- Nested navigators, modals and bottom sheets
- Pausing the timer when the app leaves the foreground
- When a pause becomes a new session
- A small timer that handles navigation and AppState together
- Navigation cases worth testing by hand
- Cleaning up durations before you summarize them
- Reading screen engagement without fooling yourself
- Doing this with Riveora
- What gets recorded
- Screens that aren’t routes
- 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:
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]']:
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:
| State | Platform | What it means |
|---|---|---|
active | Both | The app is in the foreground. |
background | Both | Another app, the home screen, or the phone is locked. On Android, also another Activity, including system screens your app opened. |
inactive | iOS only | In 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:
- Close the open screen visit with the time counted so far. It belongs to the old session.
- Start a new session.
- 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:
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:
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.
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.
Navigation cases worth testing by hand
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 (bothonReadyand 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:
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:
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.