With React Navigation 7, why does a screen-view tracker on NavigationContainer need onReady as well as onStateChange?
answer
- initial state is not a change
- onReady fires once, navigators rendered
- ref.getCurrentRoute() for the name
- compare with the previous route
- setParams also changes state
basics
~20 sReact Navigation 7 does not call onStateChange for the initial state, so a tracker using only it misses the first screen. onReady fires once when navigators are rendered, which is where the initial route is recorded.
solid answer
~50 s`onStateChange` is called with the new root state after a navigation changes it, but not for the state the container starts with. A tracker built only on it never logs the first screen — for a furniture store opened from a shared link, that is the product page that brought the customer in. The standard shape uses a container ref: in `onReady`, read `navigationRef.getCurrentRoute()?.name` and log it; in `onStateChange`, read the current route again, log it only if it differs from the stored previous name, and update the stored name. The comparison matters because params updates, tab state and nested navigators also change state without changing the screen. In v7, `onReady` fires once, when the container has navigators rendered — not merely when it mounts — so if a splash replaces the navigator, it fires after the splash.
code
tsx · 29 linesimport * as React from 'react';
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
import { RootStack } from './RootStack';
import { logScreenView } from './telemetry';
const navigationRef = createNavigationContainerRef();
export function App() {
const routeNameRef = React.useRef<string | undefined>(undefined);
return (
<NavigationContainer
ref={navigationRef}
onReady={() => {
routeNameRef.current = navigationRef.getCurrentRoute()?.name;
if (routeNameRef.current) logScreenView(routeNameRef.current);
}}
onStateChange={() => {
const current = navigationRef.getCurrentRoute()?.name;
if (current && current !== routeNameRef.current) {
logScreenView(current);
}
routeNameRef.current = current;
}}
>
<RootStack />
</NavigationContainer>
);
}go deeper
Remember that onStateChange reports changes and onReady fires once at the start, so tracking the first screen needs onReady.
Explain the ref-based pattern with getCurrentRoute, and why comparing with the previous route filters out params and nested-state updates.
Account for splashes and linking delaying onReady, the v7 change to when it fires, and whether to count repeat visits by route name or route key.
Define what a screen view means for the product's metrics and keep that definition in one container-level place rather than per screen.
## Two container callbacks with different jobs `NavigationContainer` in **React Navigation 7** exposes two callbacks that screen tracking relies on: - **`onStateChange(state)`** — called with the latest root navigation state **when it changes**. React Navigation skips the call for the container's first state; it is about changes, not the starting point. - **`onReady()`** — called **once**, when the navigation tree is ready. Since v7 it follows `navigationRef.isReady()`: it fires only when at least one navigator is rendered. Before v7 it fired when the container finished mounting and deep links were resolved. A screen-view tracker needs both: `onReady` to record the screen the app opened on, `onStateChange` for every later move. ## Why the first screen goes missing A customer taps a shared link to an oak sofa. The app launches straight into `Product`. That is the initial state, so `onStateChange` is not called for it. If the tracker only listens to `onStateChange`, the first logged screen is whatever the customer visits next — the entry page of the whole session is lost, and conversion from shared links looks worse than it is. ## The standard shape 1. Create a container ref with `createNavigationContainerRef()` (or the `useNavigationContainerRef()` hook) and pass it to `NavigationContainer`. 2. Keep the previous route name in a ref. 3. In `onReady`, read `navigationRef.getCurrentRoute()?.name`, store it and log it. 4. In `onStateChange`, read the current route name, log it **only if it differs** from the stored one, then store it. ## Why compare with the previous route `onStateChange` fires for any state change, not only for a new screen: | Change | New screen? | |---|---| | `navigate('Product', { sku })` from `Catalog` | yes | | `setParams({ qty: 2 })` on `Product` | no — same route, new params | | Switching tabs back to one already focused | no | | A nested navigator updating its own state | sometimes | Comparing names (or route keys, if two visits to `Product` with different `sku` should count separately) turns "state changed" into "screen changed". ## Interaction with auth and linking - If the app renders a splash **instead of** the navigator while it restores a session, `onReady` fires only after the navigator appears. That is usually what you want — the splash is not a screen — but anything waiting for `onReady` (hiding a launch screen, flushing a queued navigation) waits too. - With `linking`, the container waits for the initial URL to resolve before rendering the navigators (showing its `fallback` meanwhile), so the route read in `onReady` already reflects the link. - Calling navigation methods on the ref before the container is ready logs an error that the navigation object has not been initialized; `navigationRef.isReady()` is the guard for code outside screens. ## Common mistakes - Logging inside every screen's effect instead — screens in a stack stay mounted, so effects do not rerun on return, and focus-based logging must be repeated per screen. - Using `state.routes[state.index]` from the root state, which gives the focused **root** route (for example `HomeTabs`), not the innermost screen. `getCurrentRoute()` walks down to the focused leaf. - Forgetting that `onStateChange` gets called for params changes and logging the same screen repeatedly. ## Testing the tracker - Render the container with a test navigator, and assert that one screen view is logged for the initial screen before any interaction. - Navigate to another screen and back, and assert one log per real screen change. - Call `setParams` on the current screen and assert that nothing new is logged. - Start the app from a linked path and assert that the first logged screen is the linked one, not the default initial screen. ## What a strong answer shows It states that the initial state is not a change, uses `onReady` for the first screen and `onStateChange` with a previous-name comparison for the rest, reads the innermost route through the ref, and notes the v7 rule that `onReady` waits for a rendered navigator.
- Why read getCurrentRoute() instead of state.routes[state.index] in onStateChange?The state passed to `onStateChange` is the root state, so `routes[index]` is the focused root route — often a navigator such as `HomeTabs`. `getCurrentRoute()` walks the nested state down to the focused leaf screen, which is what a screen-view tracker wants.
- What changed about onReady in React Navigation 7?It now matches `navigationRef.isReady()`: it fires only once navigators are rendered. Previously it fired when the container finished mounting and deep links were resolved, even if no navigator was on screen yet — for example while a splash replaced it.
saying these in an interview costs you the question
- onStateChange is called once on mount with the initial state.
- onReady fires on every navigation, so it can replace onStateChange.
- Every onStateChange call means the user moved to a new screen.
- The root state's focused route is always the screen the user sees.