Skip to main content

Screen analytics

Every screen the user sees, sent to Firebase Analytics, Segment, PostHog or Amplitude from one place: the names of the screens, the data of a screen, modals, tabs and sheets, and the time on a screen.

Analytics wants to know which screens the user sees, and for how long. Here you report every screen from one place in createNavigation, with no code in the screens, and add the data of a screen where it matters. You end with:

  • a screen view for every screen the user sees, from the first one;
  • the data of a screen, such as the id of a product, where you need it;
  • the time the user spent on a screen.

What you need​

How it will work​

The platform moves the screens, and the navigation hears each move as an event. So screen tracking is a listener, not a component:

  • One file, src/analytics.ts, is the only one that knows your library. It has two functions: screenView(name) and event(name, properties).
  • listeners of createNavigation call screenView for every screen that appears. They are attached before the first root, so the first screen counts too.
  • A screen sends its own data with a hook, only where that data matters.

If you come from React Navigation: there the app keeps a ref to the container and reads the current route in onStateChange. Here there is no navigator state to read: the event carries the screen. Both side by side: Why the events are outside React.

1. One file for your analytics​

Put your library behind two functions. The rest of the guide calls only these, so a change of library is a change of this file.

src/analytics.ts
import {
getAnalytics,
logEvent,
logScreenView,
} from '@react-native-firebase/analytics';

type Properties = Record<string, string | number>;

const analytics = getAnalytics();

export function screenView(name: string) {
return logScreenView(analytics, {
screen_name: name,
screen_class: name,
});
}

export function event(name: string, properties?: Properties) {
return logEvent(analytics, name, properties);
}

iOS Firebase reports screens by itself on iOS, from the view controllers it sees. The screens here are view controllers of the navigation, so it would report views with the navigation's class name, such as ANNComponentViewController, as their screen class, next to your own views. Turn that off, and keep the views of screenView only:

ios/MyApp/Info.plist
<key>FirebaseAutomaticScreenReportingEnabled</key>
<false/>

Nothing is sent yet: the next step calls screenView.

2. Report every screen​

listeners of createNavigation hear the events of the whole app, for as long as it runs. screenDidAppear comes each time a screen is in place, with the screen as { id, name }:

Navigation.ts
import { createNavigation } from '@apexnative/navigation';
import { screenView } from './src/analytics';
import { HomeScreen } from './src/screens/Home';
import { ProductScreen } from './src/screens/Product';

export const Navigation = createNavigation({
screens: { Home: HomeScreen, Product: ProductScreen },
root: 'Home',
listeners: {
screenDidAppear: ({ screen }) => {
screenView(screen.name);
},
},
});

The user opens the app, then a product, then goes back: analytics gets Home, Product, Home. The listeners are attached before the first root shows, so the first screen counts too, and no screen needs a line of its own.

screen.name is the name in screens.

3. Name the screens for analytics​

The names in screens are code names. When your analytics wants other ones, or should skip a screen, keep a map next to the listener:

src/analytics.ts
const names: Record<string, string | null> = {
Home: 'home',
Product: 'product_details',
Toast: null, // not a screen view
};

export function screenName(name: string): string | null {
return name in names ? names[name] : name;
}
Navigation.ts
screenDidAppear: ({ screen }) => {
const name = screenName(screen.name);
if (name) screenView(name);
},

An overlay is a screen too, so a toast or a banner shown with showOverlay appears in the events: skip it here when it isn't a view. So does a splash of your own, before the first screen, as ApexNavigation.Splash: add 'ApexNavigation.Splash': null to the map.

4. What counts as a view​

screenDidAppear follows what the platform shows. Some moves bring a screen back, and some don't:

The userscreenDidAppear
Goes back to a screenComes again: the user sees it again.
Selects a tabComes for the screen on top of that tab.
Opens a full-screen modal, then closes itComes for the modal's screen, then again for the screen under it.
iOS Opens a sheet, then closes itComes for the sheet's screen only. The screen under a sheet still shows, so it doesn't disappear and doesn't appear again.

To count only the first time each screen opens, not the returns, keep the ids: screen.id is one showing of a screen, and a return has the same id.

const seen = new Set<string>();

// in listeners
screenDidAppear: ({ screen }) => {
if (seen.has(screen.id)) return; // a return
seen.add(screen.id);
screenView(screen.name);
},

What the user looks at, sheets included​

When a view should be the screen the user is on, the sheet's screen while it is open and the screen under it once it closes, listen to stateChanged instead. It comes each time the screen the user sees changes, and overlays don't count:

let last: string | undefined;

// in listeners
stateChanged: ({ screen }) => {
if (!screen || screen.id === last) return;
last = screen.id;
screenView(screen.name);
},

The user opens a sheet over Home and closes it: analytics gets the sheet's screen, then Home. A toast over Home sends nothing. More: What the user sees now.

5. The data of a screen​

The events carry the screen, not its props. The data that matters, such as the id of a product, is known by the screen itself, so the screen sends it with useScreenAppear:

src/screens/Product.tsx
import { useScreenAppear } from '@apexnative/navigation';
import { event } from '../analytics';

function Product({ productId }: { productId: string }) {
useScreenAppear(() => {
event('product_viewed', { productId });
});
// …
}

Send it as an event of its own, next to the screen view of step 2, so the screen isn't counted twice. The hook hears only this screen, and stops when it unmounts.

useScreenAppear comes each time the screen appears, as screenDidAppear does. A screen that can appear before its component has mounted, such as the first screen of the app, reports the first showing reliably with useFocusEffect, which runs at once when the screen already has the focus. It also runs again when a sheet over the screen goes, where useScreenAppear doesn't come.

6. The time on a screen​

screenDidDisappear comes when the screen is covered or has left. With the time of each appearance kept by id, the listener sends how long the user stayed:

Navigation.ts
const shownAt = new Map<string, number>();

export const Navigation = createNavigation({
// …
listeners: {
screenDidAppear: ({ screen }) => {
shownAt.set(screen.id, Date.now());
screenView(screen.name);
},
screenDidDisappear: ({ screen }) => {
const start = shownAt.get(screen.id);
if (start === undefined) return;
shownAt.delete(screen.id);
event('screen_time', {
screen: screen.name,
seconds: (Date.now() - start) / 1000,
});
},
},
});

The user stays on Product for 12 seconds and goes back: analytics gets 12 seconds for Product. Time with the app in the background counts here too: see The app in the background.

7. Tabs​

A tab change already comes as a screen view: the screen on top of the tab appears. To know which tab the user picked, as well, tabSelected has the index of the tab:

// in listeners
tabSelected: ({ selectedTabIndex }) => {
event('tab_selected', { index: selectedTabIndex });
},

Listening from your analytics module​

listeners belong to createNavigation. Code that starts later, such as analytics that starts only after the user agreed to it, listens with navigation.on, and stops with the function it returns:

src/analytics.ts
import { navigation } from '@apexnative/navigation';

let stop: (() => void) | undefined;

export function startAnalytics() {
stop = navigation.on('screenDidAppear', ({ screen }) => {
screenView(screen.name);
});
}

export function stopAnalytics() {
stop?.();
}

It hears the screens that appear from then on, not the one already on screen: send that one yourself, from navigation.getState().screen. In the Showcase, scenario 13.4 listens this way for 15 seconds.

Your library's provider and hooks​

Segment's AnalyticsProvider with useAnalytics, and PostHog's PostHogProvider with usePostHog, keep the client in a React context. Without appProvider, each screen is a React root of its own, so a provider around the first screen doesn't reach the others, and the hook finds no client there.

The client in a module, as in step 1, works in every screen and in the listeners, with no provider. When a component of yours needs the library's hook, give every screen the provider: App context shows the ways.

The app in the background​

A screen under the app in the background still counts as shown: no screenDidDisappear comes when the user leaves the app. To stop the clock, end the view of the current screen when the app goes to the background, and start it again when it comes back, with React Native's AppState:

src/analytics.ts
import { AppState } from 'react-native';
import { navigation } from '@apexnative/navigation';

AppState.addEventListener('change', (state) => {
const screen = navigation.getState().screen;
if (!screen) return;
if (state === 'active') {
shownAt.set(screen.id, Date.now());
} else if (state === 'background') {
endView(screen); // sends the time, as screenDidDisappear does
}
});

endView is the body of the screenDidDisappear listener of step 6, moved into a function both call. getState() is what the user sees now: the screen of the top modal, or of the root.

Testing the reports​

The events come from the native side, so Jest can't make a screen appear. Test the parts that decide: screenName with the names of your screens, and a listener written as a function of its own, called with { screen: { id: '1', name: 'Home' } }. That a real move sends a view is for a device: open the app with your library's debug view (Firebase DebugView, the live events of Segment, PostHog or Amplitude) and walk through the screens. More: Testing.

The whole code​

With the file of your library from step 1, plus the names of step 3:

Navigation.ts
import { createNavigation } from '@apexnative/navigation';
import { event, screenName, screenView } from './src/analytics';
import { HomeScreen } from './src/screens/Home';
import { ProductScreen } from './src/screens/Product';

const shownAt = new Map<string, number>();

export const Navigation = createNavigation({
screens: { Home: HomeScreen, Product: ProductScreen },
root: 'Home',
listeners: {
screenDidAppear: ({ screen }) => {
const name = screenName(screen.name);
if (!name) return;
shownAt.set(screen.id, Date.now());
screenView(name);
},
screenDidDisappear: ({ screen }) => {
const start = shownAt.get(screen.id);
if (start === undefined) return;
shownAt.delete(screen.id);
event('screen_time', {
screen: screen.name,
seconds: (Date.now() - start) / 1000,
});
},
tabSelected: ({ selectedTabIndex }) => {
event('tab_selected', { index: selectedTabIndex });
},
},
});

What's next​