React Navigation is the standard routing library for React Native. The documentation is comprehensive. It covers every feature, every option, every configuration. What it doesn't tell you is which patterns work well in production and which ones create problems as the app grows.
These are the patterns I've settled on after using React Navigation v6 across multiple production apps.
Typed navigation params
The single most important pattern. Define a param list type for every navigator and use it everywhere.
// navigation/types.ts
export type RootStackParamList = {
MainTabs: undefined;
Auth: undefined;
PostDetail: { postId: string };
UserProfile: { userId: string; source: 'feed' | 'search' };
Settings: undefined;
MediaViewer: { uri: string; type: 'image' | 'video' };
};
export type MainTabParamList = {
Home: undefined;
Search: { initialQuery?: string };
Notifications: undefined;
Profile: undefined;
};
The root navigator type describes every screen in the stack and what parameters it accepts. undefined means no params. This is the source of truth for navigation across the app.
In each screen component:
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
type Props = NativeStackScreenProps<RootStackParamList, 'PostDetail'>;
export default function PostDetailScreen({ route, navigation }: Props) {
const { postId } = route.params;
const goToUser = (userId: string) => {
navigation.navigate('UserProfile', { userId, source: 'feed' });
};
}
If you navigate with wrong params, TypeScript catches it at compile time. If you rename a screen, every reference shows an error. This eliminates an entire category of runtime crashes.
Nested navigators with type safety
Most apps have a tab navigator inside a stack navigator. The type setup for this:
import { NavigatorScreenParams } from '@react-navigation/native';
export type RootStackParamList = {
MainTabs: NavigatorScreenParams<MainTabParamList>;
Auth: undefined;
PostDetail: { postId: string };
};
export type MainTabParamList = {
Home: undefined;
Search: { initialQuery?: string };
Profile: undefined;
};
NavigatorScreenParams tells TypeScript that MainTabs contains a nested navigator. You can then navigate to a nested screen with full type checking:
navigation.navigate('MainTabs', {
screen: 'Search',
params: { initialQuery: 'react native' },
});
TypeScript knows that MainTabs has a Search screen that accepts initialQuery.
Deep linking
Deep linking lets users open the app directly to a specific screen via a URL. The setup has two parts: native configuration and React Navigation's linking config.
// navigation/linking.ts
import { LinkingOptions } from '@react-navigation/native';
import { RootStackParamList } from './types';
export const linking: LinkingOptions<RootStackParamList> = {
prefixes: ['myapp://', 'https://myapp.com'],
config: {
screens: {
MainTabs: {
screens: {
Home: '',
Search: 'search',
Profile: 'profile',
},
},
PostDetail: 'post/:postId',
UserProfile: 'user/:userId',
},
},
};
On iOS, add the URL scheme to Info.plist. On Android, add intent filters to AndroidManifest.xml. These steps are in the React Navigation docs and are straightforward.
The pattern that works: keep the URL structure flat and simple. Avoid deeply nested paths. /post/abc123 is better than /tabs/home/feed/post/abc123. The linking config handles the mapping from flat URLs to nested navigators internally.
Auth flow with conditional rendering
The pattern for authentication that avoids complexity:
function RootNavigator() {
const { user, isLoading } = useAuth();
if (isLoading) {
return <SplashScreen />;
}
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
{user ? (
<>
<Stack.Screen name="MainTabs" component={MainTabs} />
<Stack.Screen name="PostDetail" component={PostDetailScreen} />
<Stack.Screen name="Settings" component={SettingsScreen} />
</>
) : (
<Stack.Screen name="Auth" component={AuthScreen} />
)}
</Stack.Navigator>
);
}
When user changes from null to an authenticated user, React Navigation automatically navigates to the first authenticated screen. When user becomes null (logout), it navigates back to Auth. No manual navigation calls needed.
This pattern is simpler than using navigation.reset() or conditional redirects inside screens. It also handles the edge case where a deep link arrives before the user is authenticated. React Navigation queues the navigation and replays it after authentication succeeds.
Performance with heavy screens
React Navigation keeps screens mounted in memory when you navigate away from them. This is good for preserving scroll position and form state. It's bad when screens are heavy (large lists, complex layouts, media content).
The fixes I use:
Lazy loading tabs:
<Tab.Navigator screenOptions={{ lazy: true }}>
Tabs aren't rendered until the user visits them for the first time. Without lazy, all tabs render on app launch.
Unmounting on blur for specific screens:
<Stack.Screen
name="MediaViewer"
component={MediaViewerScreen}
options={{ unmountOnBlur: true }}
/>
Screens with heavy media content should be unmounted when the user navigates away. Without this, the media stays in memory.
Using useFocusEffect instead of useEffect:
import { useFocusEffect } from '@react-navigation/native';
function HomeScreen() {
useFocusEffect(
useCallback(() => {
loadData();
return () => cleanup();
}, [])
);
}
useFocusEffect runs when the screen gains focus and cleans up when it loses focus. useEffect runs when the component mounts and unmounts. Since screens stay mounted, useEffect only fires once, not every time the user returns to the screen. Use useFocusEffect for data fetching, analytics events, and state resets that should happen on every visit.
These patterns aren't original. They're all in the documentation somewhere. The value is in knowing which ones to apply by default and which ones to reach for only when needed.
The conditional navigator pattern for auth flow is the right way to do it and I've argued about this with teammates who insist on the redirect approach. Now I have something to point them to.