Screen Tracking and Guards
Log a screen view from one place instead of every screen, and keep an unauthorised user off a protected screen before it renders.
<Stack.Protected guard={isSignedIn}><Stack.Screen name="(app)" /></Stack.Protected>Two jobs sit at the same point in the app: knowing which screen the user is on, and deciding whether they are allowed on it. Both belong in a layout, not in each screen.
Tracking from one place
A useEffect in every screen looks fine and then drifts: a new screen forgets it, a tab screen that stays mounted never fires again, and a remount double-counts. Expo Router gives you the route as data instead. usePathname() returns the current path, and useGlobalSearchParams() returns the params from the whole URL rather than only the focused screen. Put one effect in the root layout and every navigation is reported, including ones through a deep link.
In plain React Navigation there is no URL, so you use useNavigationContainerRef() with the container's onReady and onStateChange, reading navigationRef.current.getCurrentRoute().name and comparing it with the previous value.
Note { pathname, params } is object property shorthand for { pathname: pathname, params: params }.
Guards
A guard must decide before the screen renders, otherwise private data flashes. In Expo Router, Stack.Protected takes a guard boolean; when it is false the screens inside are not reachable. Tabs.Protected and Drawer.Protected work the same way. The redirectTo prop that names the fallback route is Expo SDK 58 and later, so on SDK 57 pair the guard with a <Redirect href="…" /> when the destination matters.
The common bug is guarding on a value that is still loading. token === null during an async read is indistinguishable from signed out, and the user sees the login screen for a frame.
Example
// app/_layout.tsx
import { useEffect, useRef } from 'react';
import { Slot, useGlobalSearchParams, usePathname } from 'expo-router';
import { track } from '../lib/analytics';
export default function RootLayout() {
const pathname = usePathname(); // e.g. "/trip/42"
const params = useGlobalSearchParams(); // params from the whole URL
// params is a fresh object each render, so compare its contents,
// not its identity, or the same screen is logged repeatedly.
const serialized = JSON.stringify(params);
const lastSent = useRef<string>('');
useEffect(() => {
const signature = `${pathname}?${serialized}`;
if (signature === lastSent.current) {
return;
}
lastSent.current = signature;
// Object property shorthand: { pathname, params } is the same as
// { pathname: pathname, params: params }.
track('screen_view', { pathname, params });
}, [pathname, serialized, params]);
// Slot renders whichever child route is active, with no navigator
// of its own. Use <Stack /> here instead if this level needs one.
return <Slot />;
}When to use it
- Send one screen_view event per navigation from the root layout instead of adding an effect to every screen.
- Keep a signed-out user off an account screen even when a deep link points straight at it.
- Hold the splash or a spinner while a stored token is read, so the sign-in screen never flashes.
- Attach the current route name to crash reports and breadcrumbs.
More examples
Protecting a group of routes
The declarative form. Only one of the two branches is reachable at a time, so there is no imperative redirect to get wrong and no window in which the protected screen mounts. The early return sits above the JSX and below the only hook, which keeps hook order identical on every render.
// app/_layout.tsx
import { Stack } from 'expo-router';
import { useSession } from '../lib/session';
export default function RootLayout() {
const { isLoading, token } = useSession();
// Decide nothing while the stored token is still being read.
// Rendering the guard now would flash the sign-in screen.
if (isLoading) {
return null;
}
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={token !== null}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={token === null}>
<Stack.Screen name="sign-in" />
</Stack.Protected>
</Stack>
);
}A redirect guard inside a route group
Use this when the destination matters, for example sending the user to sign-in rather than to whatever the router picks. It also covers the deep-link case: a notification that opens /app/orders/3 while signed out lands here first. On SDK 58 and later the same effect is available as redirectTo on Stack.Protected.
// app/(app)/_layout.tsx
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { Redirect, Stack } from 'expo-router';
import { useSession } from '../../lib/session';
export default function AppLayout() {
const { isLoading, token } = useSession();
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator />
</View>
);
}
// Redirect renders instead of the children, so no protected screen
// mounts and no request is fired from one.
if (token === null) {
return <Redirect href="/sign-in" />;
}
return <Stack />;
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});Screen tracking without Expo Router
The pattern to recognise in an older codebase, or in one that never adopted the file-based router. getCurrentRoute walks down to the focused leaf route for you. The ref comparison is not optional: onStateChange fires on param updates as well, and without it every small state change becomes a screen view.
// App.tsx — React Navigation, no URL to read
import { useRef } from 'react';
import {
NavigationContainer,
useNavigationContainerRef,
} from '@react-navigation/native';
import { RootStack } from './RootStack';
import { track } from './lib/analytics';
export default function App() {
const navigationRef = useNavigationContainerRef();
const routeNameRef = useRef<string | undefined>(undefined);
return (
<NavigationContainer
ref={navigationRef}
onReady={() => {
// The first screen never triggers onStateChange,
// so log it when the container becomes ready.
routeNameRef.current = navigationRef.getCurrentRoute()?.name;
track('screen_view', { screen: routeNameRef.current });
}}
onStateChange={() => {
const previous = routeNameRef.current;
const current = navigationRef.getCurrentRoute()?.name;
// State changes for params and nested navigators too.
// Only log when the focused screen actually changed.
if (current !== undefined && previous !== current) {
track('screen_view', { screen: current });
}
routeNameRef.current = current;
}}
>
<RootStack />
</NavigationContainer>
);
}
Discussion