Most people who quit your app never tell you why. They don't write a review or email support. They just close it and don't come back.

But right before they leave, they almost always do something you could have seen: tapped a button five times, waited for a screen that never loaded, closed the paywall after one second.

I learned this the annoying way. My analytics only knew about what I'd added a track() call for, and on every release I'd forget a few. Worse, I usually didn't know which signal mattered until after users had already left.

So here are the seven signals I'd track in any React Native or Expo app today, with the code for each. All of it works with whatever analytics tool you use. I've written it as analytics.track(...); swap in your own.

Before you start: track screens in one place

Every signal below is more useful when you know which screen it happened on. Don't add a screen call to every screen; listen to navigation once instead. With Expo Router:

// lib/currentScreen.ts: the screen the user is on right now export const currentScreen = { name: '/' }; 
// app/_layout.tsx import { useEffect } from 'react'; import { Stack, useSegments } from 'expo-router'; import { currentScreen } from '../lib/currentScreen'; export default function RootLayout() { const segments = useSegments(); const route = '/' + segments.filter((s) => !s.startsWith('(')).join('/'); useEffect(() => { currentScreen.name = route; analytics.track('screen', { screen: route }); }, [route]); return <Stack />; } 

useSegments() gives you the route file (/product/[id]) instead of the address (/product/8812), so all product pages count as one screen. Add the app version to every event too, so you can compare releases later.

1. Rage taps

Someone taps the same thing three or more times in a second. It almost always means "this isn't working": a button that's too slow to respond, a coupon field that does nothing, a spinner that never ends.

const recent = new Map<string, number[]>(); export function noteTap(label: string) { const now = Date.now(); const taps = (recent.get(label) ?? []).filter((t) => now - t < 1000); taps.push(now); recent.set(label, taps); if (taps.length === 3) { analytics.track('rage_tap', { label, screen: currentScreen.name }); } } 

Call noteTap() from the onPress of your buttons (a small wrapper around Pressable saves you doing it by hand everywhere). Use the button's accessibilityLabel or text as the label.

2. Dead taps

The quieter cousin of rage taps: people tapping things that aren't buttons. A product image they expect to zoom, a banner that looks clickable, a price they think opens details.

These are some of the most useful signals, because they tell you what users expect your app to do. They're also the hardest to catch by hand: the thing being tapped has no onPress, so there's nothing to hook into. You'd have to listen to every touch at the root of the app and work out which ones nothing responded to. If you only take one idea from this section, take this: when you see many taps on something that isn't interactive, make it interactive.

3. Failed network requests

When a request fails, the user usually sees a spinner, an empty list or nothing at all, and quietly gives up. Your backend logs might show it, but not which screen it broke or what the user did next.

Wrap fetch once, at startup:

const originalFetch = global.fetch; global.fetch = async (...args: Parameters<typeof fetch>) => { const started = Date.now(); try { const res = await originalFetch(...args); if (!res.ok) { analytics.track('request_failed', { status: res.status, ms: Date.now() - started, screen: currentScreen.name, }); } return res; } catch (err) { analytics.track('request_failed', { status: 0, screen: currentScreen.name }); throw err; } }; 

Don't send full URLs: they often contain user IDs, emails or tokens. The status code and the screen are usually enough.

4. Crashes and JavaScript errors

React Native lets you hook into every uncaught JavaScript error:

const previousHandler = ErrorUtils.getGlobalHandler(); ErrorUtils.setGlobalHandler((error, isFatal) => { analytics.track('js_error', { message: error.message, fatal: Boolean(isFatal), screen: currentScreen.name, }); previousHandler(error, isFatal); }); 

One catch: when the error is fatal, the app may close before the event is sent. Save events to storage first and send them on the next launch, otherwise you'll miss exactly the crashes that matter most.

5. Slow screens

People will wait a second or two. After that, many of them leave. Measure from the moment a screen opens to the moment its content is ready:

function ProductScreen() { const { data } = useProduct(); const openedAt = useRef(Date.now()); const reported = useRef(false); useEffect(() => { if (data && !reported.current) { reported.current = true; analytics.track('screen_ready', { screen: currentScreen.name, ms: Date.now() - openedAt.current, }); } }, [data]); // ... } 

Look at the slowest 10% of visits, not the average. The average hides the people on bad connections, and they're the ones leaving.

6. The paywall closed in under two seconds

Counting paywall views tells you little. How long people stay on the paywall tells you a lot: closing it in a second means they didn't read it, usually because it appeared too early.

import { useCallback } from 'react'; import { useFocusEffect } from 'expo-router'; // or @react-navigation/native function Paywall() { useFocusEffect( useCallback(() => { const openedAt = Date.now(); return () => { analytics.track('paywall_closed', { seconds: (Date.now() - openedAt) / 1000, }); }; }, []), ); // ... } 

If most closes happen in under two seconds, try showing the paywall later, after the user has done the thing your app is for.

7. The last screen before they leave

Finally, the simplest and maybe the most telling one: which screen people are on when they leave the app.

import { AppState } from 'react-native'; AppState.addEventListener('change', (state) => { if (state === 'background') { analytics.track('app_left', { screen: currentScreen.name }); } }); 

Every app has screens where visits naturally end (a confirmation page, say). The interesting ones are screens where many visits end that shouldn't be the end: halfway through onboarding, the cart, the sign-up form. Compare them with the signals above. A screen with lots of exits and lots of rage taps is usually your most expensive bug.

The catch

That's seven pieces of code to write, test, and remember on every release. Dead taps are very hard to do by hand. And each new screen or button is one more place to forget.

That's the problem I kept running into, so full disclosure: I'm the developer of Riveora, an analytics tool for React Native and Expo apps that records all of this automatically, without track() calls. You add one provider:

<RiveoraProvider projectKey="pk_..." storage={storage}> <ExpoRouterTracker /> <Stack /> </RiveoraProvider> 

From then on, screens, taps, rage taps, dead taps, crashes, failed requests and where visits end are recorded automatically, each with the app version. Paywalls and onboarding take one line each. The dashboard ranks the problems by the sales they cost you and tells you after a release whether a problem is fixed. There's no screen recording, and nothing users type is ever read.

It's free to try, no card needed: riveora.com

But everything above works without it, and even just #1, #3 and #7 will show you things about your app you didn't know.

Which of these have you been bitten by? And is there a signal you watch that's not on this list? I'd love to hear what others track.