Skip to content
Sunstone Apps
← Back to blog
React Native Analytics

GA4 screen tracking in React Native: why custom events are not enough

The app was sending game_started and shop_viewed, but GA4 could not answer where users actually navigated. The fix was not more custom events — it was screen_view wired to React Navigation.

Published: 9 min read
OpenGraph preview image for this article. GA4 screen tracking in React Native: why custom events are not enough

Open Pages and screens in GA4 for a React Native app and find zero views, most traffic labeled (not set). That is usually not a Google bug — the app is simply not sending screen_view.

That is what we saw on Daily Sudoku: Offline Puzzle after internal testing in July 2026. GA4 already had roughly four hundred events over a few days. game_started, shop_viewed, and home_play_pressed sat at the top of the list. The navigation report did not.

Custom events tell you what happened. They do not tell you which screen the player was on when they tapped Play, opened Shop, or abandoned mid-game.

Phase 1 that month extended @apps/analytics, wired screen_view to React Navigation, synced user properties, configured GA4 Admin, and validated on a production APK. The annoying part was the physical device: empty DebugView, logcat doing the real work.

What GA4 was already showing

The Firebase project sunstoneapps-daily-sudoku feeds the GA4 property sunstoneapps-daily-sudoku-app. Gameplay telemetry was alive:

  • shop_viewed, app_opened, game_started, home_play_pressed, difficulty_selected, game_finished, and more.

That is useful for funnels built from explicit taps and lifecycle hooks. It is not a substitute for screen-level reporting. GA4’s Pages and screens report expects screen_view (or the automatic screen reporting that Firebase can do when configured). Without it, product questions like “do users open Shop before or after their first game?” require fragile inference from event order.

We also had parameters on events (difficultyId, result, source) that only become easy dimensions after registration in GA4 Admin. Raw parameter names in the event stream are not the same as report-ready dimensions.

Design goals for Phase 1

We scoped Phase 1 to visibility, not new gameplay telemetry:

  1. Automatic screen_view on every React Navigation route change.
  2. setUserProperty for a small set of non-PII segmentation fields.
  3. GA4 custom dimensions for event parameters and user properties we already send.
  4. Key events marked for the metrics that matter to the business.
  5. Validation on a production-like APK, not only in mock providers.

Ads funnels, abandonment, and Daily habit loops wait for later. Phase 1 only had to make the event contract the app already sent readable in GA4. (Setting up AdMob UMP privacy messages was a separate concern that came later.)

Extending @apps/analytics

The monorepo already routed product events through @apps/analytics with a Firebase provider on native and a no-op on web. We added two methods to the public surface:

  • logScreenView({ screenName, screenClass? })
  • setUserProperty({ name, value })

Both respect the same analytics.enabled gate as trackEvent. On native they call @react-native-firebase/analytics (logScreenView, setUserProperty). On web they noop so SSR and Expo web builds do not crash.

The Firebase runtime lives in firebaseRuntime.ts so Jest and web stubs stay thin. Unit tests mock the provider and assert dispatch when analytics is enabled.

Rule we kept: do not log PII. User properties are product state (theme_mode, locale, max_mistakes_setting), not emails or account identifiers.

Wiring React Navigation

Sudoku uses a single NavigationContainer in AppNavigator.tsx. The pattern:

  1. Keep a ref to the navigation container.
  2. On onReady and onStateChange, read the active route name.
  3. If the name changed since the last emission, call logScreenView.

A helper getActiveRouteName walks nested navigators so a tab inside a stack still resolves to the leaf route (Game, Shop, WinFlow, etc.). The hook useAnalyticsNavigationTracking owns the “last route” deduplication so duplicate state events do not spam analytics.

Mapped screens for the MVP navigator: Home, Profile, Favorites, Shop, Settings, PreGameAd, Game, WinFlow, WinStats. screen_class mirrors the route name, which aligns with Firebase’s firebase_screen_class builtin when reports hydrate.

SyncAnalyticsUserProperties is a small component mounted near the app root. It watches app version, locale, theme mode, max-mistakes setting, tutorial completion, and no-ads entitlement, calling setUserProperty when values change. Tutorial completion updates when the welcome flow finishes, not only on cold start.

What landed in GA4 Admin

After code was ready, we configured the property (manual steps in Admin; some are tedious to automate because GA4’s UI uses overlays and comboboxes):

Event-scoped custom dimensions (8): difficultyId, result, source (labeled “Game start source”), productId, step, stepId, scoreBucket, hasActiveGame.

User-scoped custom dimensions (6): app_version, locale, theme_mode, max_mistakes_setting, tutorial_completed, has_noads_entitlement.

Key events: game_finished, daily_complete, shop_purchase_completed (plus first_open from Firebase defaults).

Report snapshot: the reporting hub shows active users, event counts, retention, and event breakdowns. Pages and screens will only populate after devices run a build that emits screen_view — expect 24–48 hours of lag after release.

We skipped a derived game_won event in Admin. Filtering game_finished where result = win is enough; creating derived events in GA4’s “no-code” UI defaults to screen-name triggers and is easy to misconfigure.

Optional manual follow-up: save a funnel exploration app_opened → home_play_pressed → game_started → game_finished from the Library template Funnel exploration.

Validating on a production APK

Debug builds are a poor stand-in for store behavior. We used:

pnpm sudoku:apk:prd
adb shell setprop debug.firebase.analytics.app com.sunstoneapps.dailysudoku

Install the release APK, cold-start the app, navigate Home → Shop → Game.

When DebugView goes blank — and logcat saves the day

On an earlier Play build we had already hit this: on Xiaomi/HyperOS the DebugView device picker stays empty even with debug.firebase.analytics.app set, logcat showing Faster debug mode event logging enabled, and uploads returning 204 to GOOGLE_ANALYTICS. GA Realtime showed active users; only the debug UI refused to list the phone.

For Phase 1 we ran the same check on a Redmi Note 7 (94fed41):

adb logcat -s FA-SVC | rg "screen_view|Setting user property|app_opened"

Observed on cold start:

  • Logging event: name=screen_view(_vs) with ga_screen(_sn)=Home, ga_screen_class(_sc)=Home, manual_tracking(_mst)=1
  • Setting user property: theme_mode, light (and the other five properties)
  • app_opened with environment=production

That is enough to ship Phase 1 code. GA4’s UI catches up after volume accumulates.

Operational checklist we keep in the repo

The living checklist is docs/app-sudoku/SUDOKU_ANALYTICS_OBSERVABILIDADE_CHECKLIST.md. It phases work:

  • Phase 1: journey visibility (screen_view, dimensions, key events) — largely done.
  • Phase 2: ads and abandonment instrumentation in @apps/ads and gameplay bridges.
  • Phase 3: Daily habit and settings events.
  • Phase 4: BigQuery export when real user volume justifies SQL.

The typed event contract stays in apps/sudoku/src/app/analytics/events.ts and the Telemetria section of SUDOKU_PLANO.md. When you add events, update types, docs, and GA4 dimensions in the same change.

Three mistakes that almost cost us a week

Creating GA4 dimensions only after the report is empty wastes a week. Parameters hit the raw stream immediately; they do not become filters until you register custom dimensions — we now do that in the same PR as the code.

DebugView is not the only proof. On the Redmi, adb logcat -s FA-SVC with _dbg=1 and a 204 upload proved transport while the console picker stayed empty.

screen_view beats the tenth custom gameplay event. GA4 became readable before any new gameplay telemetry we were not segmenting yet.

Analytics stays out of devModeEnabled and off the critical gameplay path — fire-and-forget, warn on failure, never block the player with a modal.

After that

Phase 2 covers ad lifecycle (ad_request, ad_loaded, ad_failed, …) and game_abandoned for sessions that end without game_finished. Monetization and drop-off only become measurable once that exists.

If Pages and screens is empty and Firebase is already in your React Native app, start with screen_view and a handful of user properties. The rest waits.

Contact Sunstone Apps