My first React Native app had twelve screens and zero navigation structure. None. Users tapped a button, landed somewhere unexpected, hit the back button, and ended up on an entirely different screen than where they started. One beta tester told me she felt like she was “inside a broken elevator that only sometimes goes to the right floor.” Brutal, but accurate. I’d wired everything together with conditional rendering and state flags — a rats’ nest of if statements pretending to be navigation. It sort of worked on the happy path. Everywhere else, total chaos.
Took me about two days of that feedback before I ripped out the whole thing and installed React Navigation. Should’ve done it from the start, honestly. Probably saved myself forty hours of debugging by switching. And now, a few years later in 2026, React Navigation is still the library most React Native developers reach for when they need to move users between screens. Not the only option — there’s react-native-navigation by Wix, which uses native navigation controllers under the hood, and some newer entrants have popped up — but React Navigation dominates the ecosystem for good reason. It’s flexible, well-documented, and plays nicely with TypeScript.
So here’s what we’re going to do. I’ll walk you through the three core navigator types: stack, tabs, and drawer. We’ll build each one with working TypeScript code. And then we’ll nest them together the way you’d actually structure a production app. Sound good? Let’s get into it.
Getting React Navigation Installed
Before anything works, you need the packages. React Navigation is modular, which I appreciate — you install only what you need rather than pulling in some massive monolith. Here’s the full installation for all three navigator types we’ll cover:
# Core library
npm install @react-navigation/native
# Required peer dependencies
npm install react-native-screens react-native-safe-area-context
# Navigator packages (install the ones you need)
npm install @react-navigation/native-stack
npm install @react-navigation/bottom-tabs
npm install @react-navigation/drawer
# Drawer requires gesture handler and reanimated
npm install react-native-gesture-handler react-native-reanimated
# iOS pods
cd ios && pod install && cd ..
Quick note on that last line. If you’re running on a Mac and building for iOS, the pod install step is mandatory. Miss it and you’ll get cryptic linker errors that send you down a two-hour Stack Overflow rabbit hole. Ask me how I know. On Android, react-native-screens requires a tiny tweak to your MainActivity — the React Navigation docs explain it clearly, and it’s like three lines of code.
Once everything’s installed, you wrap your entire app in a NavigationContainer. One container, at the root level. Everything else goes inside it:
// App.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { RootNavigator } from './navigation/RootNavigator';
export default function App(): React.JSX.Element {
return (
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
);
}
Pretty minimal, right? NavigationContainer manages the navigation tree and holds the state. You only ever need one of these. Nesting a second one inside would cause weird bugs, so don’t do it — learned that the hard way too, back when I was trying to isolate a modal flow in its own container. Just… don’t.
Stack Navigator: How Screens Stack Up
Picture a deck of cards. You start with one card on the table. Tap something, and a new card slides on top. Tap the back button, that top card slides away, revealing what was underneath. Stack navigation works exactly like that. It’s probably the most fundamental navigation pattern in mobile apps, and it’s where most people start when building their first React Native project.
Before writing any screen components, you’ll want to define your route parameters. With TypeScript, this means creating a type that maps each route name to its expected params. Might seem like extra work upfront, but it catches so many bugs at compile time that it pays for itself within the first week of development:
// navigation/types.ts
export type RootStackParamList = {
Home: undefined;
Details: { itemId: number; title: string };
Profile: { userId: string };
};
// screens/HomeScreen.tsx
import React from 'react';
import { View, Text, FlatList, TouchableOpacity, StyleSheet } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
type Props = NativeStackScreenProps<RootStackParamList, 'Home'>;
const ITEMS = Array.from({ length: 20 }, (_, i) => ({
id: i + 1,
title: `Item ${i + 1}`,
}));
export function HomeScreen({ navigation }: Props): React.JSX.Element {
return (
<FlatList
data={ITEMS}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.card}
onPress={() =>
navigation.navigate('Details', {
itemId: item.id,
title: item.title,
})
}
>
<Text style={styles.cardTitle}>{item.title}</Text>
<Text style={styles.cardArrow}>→</Text>
</TouchableOpacity>
)}
/>
);
}
const styles = StyleSheet.create({
list: { padding: 16 },
card: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
marginBottom: 8,
backgroundColor: '#fff',
borderRadius: 8,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
},
cardTitle: { fontSize: 16, fontWeight: '600' },
cardArrow: { fontSize: 18, color: '#6366F1' },
});
// screens/DetailsScreen.tsx
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
type Props = NativeStackScreenProps<RootStackParamList, 'Details'>;
export function DetailsScreen({ route, navigation }: Props): React.JSX.Element {
const { itemId, title } = route.params;
React.useEffect(() => {
navigation.setOptions({ title });
}, [navigation, title]);
return (
<View style={styles.container}>
<Text style={styles.heading}>{title}</Text>
<Text style={styles.detail}>Item ID: {itemId}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
heading: { fontSize: 28, fontWeight: '700', marginBottom: 8 },
detail: { fontSize: 16, color: '#666' },
});
Let me walk through what’s happening here. HomeScreen renders a FlatList with twenty items. When someone taps an item, navigation.navigate pushes the Details screen onto the stack, passing along itemId and title as parameters. Over in DetailsScreen, we pull those params off route.params and dynamically set the header title using setOptions. Clean and type-safe.
One thing I want to call out — see how Home has undefined as its param type? Means you can navigate to Home without passing anything. But Details requires both itemId and title. If you tried to navigate to Details without those, TypeScript would yell at you immediately. During a recent project for a fintech startup here in Bangalore, that type safety alone caught maybe five or six routing bugs before they ever hit a test device.
Now let’s wire these screens into an actual Stack Navigator:
// navigation/StackNavigator.tsx
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import type { RootStackParamList } from './types';
import { HomeScreen } from '../screens/HomeScreen';
import { DetailsScreen } from '../screens/DetailsScreen';
const Stack = createNativeStackNavigator<RootStackParamList>();
export function StackNavigator(): React.JSX.Element {
return (
<Stack.Navigator
initialRouteName="Home"
screenOptions={{
headerStyle: { backgroundColor: '#4F46E5' },
headerTintColor: '#fff',
headerTitleStyle: { fontWeight: '700' },
}}
>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Details" component={DetailsScreen} />
</Stack.Navigator>
);
}
Not much ceremony here, and that’s kind of the point. createNativeStackNavigator uses the platform’s native navigation primitives under the hood — UINavigationController on iOS, Fragment transactions on Android. So your transitions feel genuinely native. Older versions of React Navigation used a JavaScript-based stack that could feel janky on lower-end devices, but the native stack navigator fixed that. If you’re starting a new project today, always go with @react-navigation/native-stack over the older @react-navigation/stack.
The screenOptions prop sets defaults for every screen in the navigator. You can override them per-screen using the options prop on individual Stack.Screen components. I usually set a consistent header style at the navigator level and only customize individual screens when they genuinely need something different — a transparent header for a hero image screen, for example.
Tab Navigator: Bottom Navigation That Users Already Understand
Here’s something I’ve noticed across probably dozens of apps I’ve either built or reviewed: bottom tabs are the single most intuitive navigation pattern for mobile users. People just get it. Instagram, Twitter, WhatsApp — they all use bottom tabs for top-level sections. Your users’ muscle memory is already trained.
React Navigation’s bottom tab navigator makes this straightforward:
// navigation/TabNavigator.tsx
import React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { StackNavigator } from './StackNavigator';
import { ProfileScreen } from '../screens/ProfileScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
type TabParamList = {
HomeTab: undefined;
ProfileTab: undefined;
SettingsTab: undefined;
};
const Tab = createBottomTabNavigator<TabParamList>();
export function TabNavigator(): React.JSX.Element {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: false,
tabBarActiveTintColor: '#4F46E5',
tabBarInactiveTintColor: '#9CA3AF',
tabBarIcon: ({ color, size }) => {
const icons: Record<string, string> = {
HomeTab: '\u2302',
ProfileTab: '\u263A',
SettingsTab: '\u2699',
};
return (
<Text style={{ fontSize: size, color }}>
{icons[route.name] || '?'}
</Text>
);
},
})}
>
<Tab.Screen
name="HomeTab"
component={StackNavigator}
options={{ tabBarLabel: 'Home' }}
/>
<Tab.Screen
name="ProfileTab"
component={ProfileScreen}
options={{ tabBarLabel: 'Profile' }}
/>
<Tab.Screen
name="SettingsTab"
component={SettingsScreen}
options={{ tabBarLabel: 'Settings' }}
/>
</Tab.Navigator>
);
}
Notice something important here. HomeTab doesn’t point to a single screen — it points to our entire StackNavigator. Nesting navigators like this is extremely common and honestly pretty elegant once you wrap your head around it. When a user is on the Home tab and taps an item, the Details screen pushes onto the stack within that tab. Switching to the Profile tab doesn’t blow away the stack state — go back to Home tab and you’re still on Details where you left off. Each tab maintains its own independent navigation history.
For icons, I’m using Unicode characters here to keep the example self-contained. In a real app, you’d probably use react-native-vector-icons or the Expo icons package. Something like Ionicons or MaterialCommunityIcons gives you hundreds of crisp, scalable icons that look great on any screen density. I’ve been using @expo/vector-icons lately even in bare React Native projects — it bundles cleanly and the icon selection is solid.
Also worth mentioning: headerShown: false in the tab’s screenOptions. Without that, you’d get a double header — one from the tab navigator and one from the nested stack navigator. Looks terrible. Setting headerShown: false at the tab level lets the stack navigator handle headers on its own terms.
Drawer Navigator: When You Need a Side Menu
Drawers — those slide-out side menus — have fallen a bit out of fashion for consumer apps, but they’re still incredibly useful in certain contexts. Admin panels, settings-heavy apps, enterprise tools with lots of top-level sections. Gmail still uses one. So does Slack’s mobile app, sort of. If your app has more sections than can comfortably fit in a bottom tab bar (more than five is usually the threshold where tabs start feeling cramped), a drawer might be the right call.
React Navigation’s drawer requires two extra dependencies — react-native-gesture-handler and react-native-reanimated. We installed both earlier, but if you skipped those install commands, go back and grab them. Without gesture handler, swipe-to-open won’t work. Without reanimated, animations will be choppy or missing entirely.
Here’s a drawer with a custom content area:
// navigation/DrawerNavigator.tsx
import React from 'react';
import {
createDrawerNavigator,
DrawerContentScrollView,
DrawerItemList,
DrawerItem,
} from '@react-navigation/drawer';
import { View, Text, StyleSheet } from 'react-native';
import { TabNavigator } from './TabNavigator';
import { AboutScreen } from '../screens/AboutScreen';
type DrawerParamList = {
Main: undefined;
About: undefined;
};
const Drawer = createDrawerNavigator<DrawerParamList>();
function CustomDrawerContent(props: any): React.JSX.Element {
return (
<DrawerContentScrollView {...props}>
<View style={styles.header}>
<Text style={styles.headerText}>MyApp</Text>
<Text style={styles.headerSub}>v1.0.0</Text>
</View>
<DrawerItemList {...props} />
<DrawerItem
label="Logout"
onPress={() => console.log('Logout pressed')}
/>
</DrawerContentScrollView>
);
}
export function DrawerNavigator(): React.JSX.Element {
return (
<Drawer.Navigator
drawerContent={(props) => <CustomDrawerContent {...props} />}
screenOptions={{
drawerActiveTintColor: '#4F46E5',
headerStyle: { backgroundColor: '#4F46E5' },
headerTintColor: '#fff',
}}
>
<Drawer.Screen name="Main" component={TabNavigator} />
<Drawer.Screen name="About" component={AboutScreen} />
</Drawer.Navigator>
);
}
const styles = StyleSheet.create({
header: { padding: 20, borderBottomWidth: 1, borderBottomColor: '#E5E7EB' },
headerText: { fontSize: 20, fontWeight: '700' },
headerSub: { fontSize: 14, color: '#6B7280', marginTop: 4 },
});
CustomDrawerContent is where things get interesting. Instead of just rendering the default list of drawer items, we’ve added a header section at the top (app name, version number) and a Logout button at the bottom. In production, you’d probably put the user’s avatar and name in that header, maybe their subscription tier. I’ve seen apps add a dark mode toggle right there in the drawer — works surprisingly well as a UX pattern.
The drawerContent prop on Drawer.Navigator accepts a function that receives the default props. Spread those into DrawerContentScrollView and DrawerItemList, and you get all the built-in behavior (active item highlighting, proper scroll handling) while adding whatever custom elements you want around it. Flexible without being complicated.
And look at what Main renders. It’s our entire TabNavigator, which itself contains a StackNavigator in the Home tab. We’re three levels deep now, and every level handles its own concerns independently.
Nesting All Three: How It Comes Together in Production
Real apps don’t use just one navigator type. Almost every app I’ve shipped has had at least two levels of nesting, and most have three. The typical pattern goes: drawer wraps tabs, tabs wrap stacks. Makes sense if you think about it — the drawer holds the broadest sections, tabs organize the main ones, and stacks handle drilling into detail views.
Our RootNavigator is dead simple because we’ve already done the hard work in the individual navigator files:
// navigation/RootNavigator.tsx
import React from 'react';
import { DrawerNavigator } from './DrawerNavigator';
export function RootNavigator(): React.JSX.Element {
// In a real app you might check auth state here
// and conditionally render an auth stack
return <DrawerNavigator />;
}
Hierarchy: Drawer contains Tabs, Tabs contain Stack. Each manages its own state. When you push a Details screen inside the Home tab, the tab navigator doesn’t care — it just knows something is rendered in that tab slot. Similarly, opening the drawer doesn’t reset the tab state. Everything is encapsulated.
In a production setup, you’d likely add an authentication flow here. Something like checking whether the user has a valid token in AsyncStorage or SecureStore, then conditionally rendering either an auth stack (login, signup, forgot password) or the main DrawerNavigator. React Navigation handles this conditional rendering gracefully — it automatically manages the transition between navigator trees without you needing to manually reset state or call navigation.reset().
I should mention that the nesting order isn’t set in stone, though. For one client project back in late 2024, we actually put the tab navigator at the top level and only used a drawer within the settings section. Depends on your app’s information architecture. Don’t force a pattern just because a blog post told you to — including this one. Think about how your users actually move through your content and design the navigation around that.
Stuff That’ll Bite You If You’re Not Careful
After building maybe fifteen or twenty apps with React Navigation over the years, I’ve got a little mental list of gotchas. Here are the ones that seem to trip people up most often.
Deep linking configuration is more work than you’d expect. React Navigation supports it, and the docs are pretty thorough, but getting the URL patterns right across both iOS universal links and Android app links requires touching native configuration files. Budget extra time for it. Seriously. I usually block off a full day just for deep linking setup and testing, and I’ve done it enough times that I should be faster. It’s just fiddly.
Navigation state persistence — saving and restoring the user’s position when the app reopens — is doable but has edge cases. If your screen structure changes between app versions (you renamed a route, removed a screen), the persisted state might reference screens that no longer exist. React Navigation handles this somewhat gracefully, but you’ll want to test version upgrade scenarios.
Performance with deeply nested navigators can become an issue on lower-end Android devices. Each navigator level adds overhead. If you’ve got a drawer wrapping tabs wrapping stacks wrapping more stacks, and each of those stacks has ten screens… well, the initial render tree gets hefty. I’d recommend lazy loading screens using React.lazy or the lazy prop on individual Screen components if you’re noticing slow startup times.
Don’t ignore the useFocusEffect hook. Regular useEffect runs when a component mounts, but in a stack navigator, screens aren’t unmounted when you navigate away — they’re just hidden. So if you need to refresh data when a user returns to a screen (not just when it first loads), useFocusEffect is what you want. I’ve seen a lot of bugs from developers using useEffect where they needed useFocusEffect.
TypeScript integration, while excellent, has a learning curve. Getting the types right for nested navigators requires CompositeScreenProps or the useNavigation hook with explicit type parameters. It can feel verbose at first. Stick with it — once your types are set up, navigating between deeply nested screens with full autocompletion and type checking feels almost magical.
A Few Patterns That Have Worked Well for Me
Keep all your navigator definitions in a single navigation/ folder. Types in types.ts, each navigator in its own file, and a RootNavigator.tsx that composes them. I’ve tried scattering navigator code alongside screen components and it always becomes a mess. Centralize it.
Create a custom hook for navigation that wraps useNavigation with your app’s types. Something like useAppNavigation that returns a properly typed navigation object. Saves you from writing the same type annotation everywhere. Small thing, but it adds up across a hundred-screen app.
For animations, @react-navigation/native-stack gives you platform-default transitions for free. But if you want custom transitions — a modal sliding up from the bottom, a fade between tabs — look into screenOptions.animation and screenOptions.presentation. Setting presentation: 'modal' on a screen group gives you the iOS-style card presentation without any custom animation code. Clean and effective.
Test your navigation on both platforms early. I can’t count the number of times a navigation flow worked perfectly on iOS and then had subtle differences on Android — gesture behavior, header rendering, status bar overlap. The native stack navigator has mostly eliminated these inconsistencies, but edge cases still pop up. Especially around the gesture handler. Android’s back gesture (that swipe from the edge) interacts differently with React Navigation’s gesture handling compared to iOS. Test it. Don’t assume.
And if you’re building something that needs shared element transitions — where an image on one screen smoothly animates into its position on the next screen — React Navigation added experimental support for that in recent versions. It’s still marked experimental as of early 2026, so maybe don’t ship it in a production app without thorough testing. But it looks promising. Worth keeping an eye on.
For anything beyond what we’ve covered here — deep linking, authentication flows, state persistence, screen tracking for analytics — the official docs at reactnavigation.org remain the best resource. They’re comprehensive, kept up to date, and include interactive examples you can run in the browser. Probably the best-documented library in the React Native ecosystem, honestly.
Good navigation doesn’t just move users between screens — it makes them forget they’re navigating at all.