Module 6: Navigation with Expo Router
Nested Navigation
Building complex navigation architectures with multiple navigators
π― Learning Objectives
- Understand how nested navigators work together
- Design navigation architectures for complex apps
- Navigate between nested navigators correctly
- Access parent and child navigation states
- Implement drawer navigation within tab structures
- Handle common nesting patterns and edge cases
- Reset navigation state programmatically
Nesting Concepts
Nested navigation occurs when one navigator is rendered inside a screen of another navigator. This creates a hierarchy where each navigator manages its own navigation state independently.
How Nesting Works
In Expo Router, nesting happens automatically based on your file structure. A folder with a _layout.tsx file creates a new navigator level.
app/
βββ _layout.tsx # Level 1: Root Stack
βββ (tabs)/
βββ _layout.tsx # Level 2: Tab Navigator
βββ home/
βββ _layout.tsx # Level 3: Home Stack
βββ index.tsx
βββ details.tsx
Key Nesting Principles
π Nesting Rules
| Principle | Description |
|---|---|
| Independent State | Each navigator maintains its own navigation state |
| Parent Controls Child | Parent navigator determines if child navigator is visible |
| Events Bubble Up | Navigation events propagate from child to parent |
| Options Merge | Screen options from different levels are merged |
| Headers Stack | Each navigator can show its own header (hide parent's to avoid double headers) |
Avoiding Double Headers
A common issue with nesting is double headers. When both parent and child navigators show headers, you see two navigation bars.
// Problem: Both stack and tabs show headers
// app/_layout.tsx
<Stack>
<Stack.Screen name="(tabs)" /> {/* Shows header */}
</Stack>
// app/(tabs)/_layout.tsx
<Tabs>
<Tabs.Screen name="home" /> {/* Also shows header */}
</Tabs>
// Solution: Hide the parent's header for the nested navigator
// app/_layout.tsx
<Stack>
<Stack.Screen
name="(tabs)"
options={{ headerShown: false }} {/* Hide this header */}
/>
</Stack>
β Header Strategy
Generally, show headers at the innermost navigator levelβthe one closest to your screens. Hide headers on wrapper navigators that exist purely for structural purposes.
Common Architectures
Let's explore navigation architectures used by popular apps.
Architecture 1: Simple App (Stack with Tabs)
For apps with a main tab interface and occasional full-screen flows:
flowchart TD
subgraph Root["Root Stack"]
A["(tabs)"]
B[Login]
C[Onboarding]
D[Settings Modal]
end
subgraph Tabs["Tab Navigator"]
E[Home]
F[Search]
G[Profile]
end
A --> Tabs
style A fill:#fff3cd
style B fill:#fce4ec
style C fill:#fce4ec
style D fill:#fce4ec
app/
βββ _layout.tsx # Root Stack
βββ login.tsx # Modal
βββ onboarding.tsx # Full screen
βββ settings.tsx # Modal
βββ (tabs)/
βββ _layout.tsx # Tab Navigator
βββ index.tsx # Home
βββ search.tsx # Search
βββ profile.tsx # Profile
Architecture 2: E-commerce App (Tabs with Stacks)
Each tab has its own navigation stack for drilling into content:
app/
βββ _layout.tsx # Root Stack
βββ checkout.tsx # Modal (outside tabs)
βββ product-quick-view.tsx # Modal
βββ (tabs)/
βββ _layout.tsx # Tab Navigator
βββ home/
β βββ _layout.tsx # Home Stack
β βββ index.tsx # Featured products
β βββ category/[id].tsx # Category listing
β βββ product/[id].tsx # Product detail
βββ search/
β βββ _layout.tsx # Search Stack
β βββ index.tsx # Search screen
β βββ results.tsx # Results
βββ cart/
β βββ _layout.tsx # Cart Stack
β βββ index.tsx # Cart contents
β βββ saved.tsx # Saved for later
βββ account/
βββ _layout.tsx # Account Stack
βββ index.tsx # Profile
βββ orders.tsx # Order history
βββ orders/[id].tsx # Order detail
βββ settings.tsx # Settings
Architecture 3: Social App (Complex Nesting)
Social apps often have deeply nested navigation with shared screens:
app/
βββ _layout.tsx # Root Stack
βββ (auth)/ # Auth group (unauthenticated)
β βββ _layout.tsx # Auth Stack
β βββ login.tsx
β βββ register.tsx
β βββ forgot-password.tsx
βββ (main)/ # Main group (authenticated)
β βββ _layout.tsx # Main Stack (wraps tabs)
β βββ create-post.tsx # Modal
β βββ comments/[id].tsx # Modal
β βββ user/[id].tsx # User profile (accessible from anywhere)
β βββ (tabs)/
β βββ _layout.tsx # Tab Navigator
β βββ feed/
β β βββ _layout.tsx
β β βββ index.tsx # Feed
β β βββ post/[id].tsx # Post detail
β βββ explore/
β β βββ _layout.tsx
β β βββ index.tsx
β β βββ hashtag/[tag].tsx
β βββ notifications.tsx # Single screen
β βββ profile/
β βββ _layout.tsx
β βββ index.tsx # Own profile
β βββ edit.tsx
β βββ followers.tsx
β βββ following.tsx
βββ +not-found.tsx
Implementation Example
// app/_layout.tsx - Root level
import { Stack } from 'expo-router';
import { useAuth } from '@/hooks/useAuth';
export default function RootLayout() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <SplashScreen />;
}
return (
<Stack screenOptions={{ headerShown: false }}>
{!isAuthenticated ? (
<Stack.Screen name="(auth)" />
) : (
<Stack.Screen name="(main)" />
)}
</Stack>
);
}
// app/(main)/_layout.tsx
import { Stack } from 'expo-router';
export default function MainLayout() {
return (
<Stack>
<Stack.Screen
name="(tabs)"
options={{ headerShown: false }}
/>
<Stack.Screen
name="create-post"
options={{
presentation: 'modal',
title: 'Create Post',
}}
/>
<Stack.Screen
name="comments/[id]"
options={{
presentation: 'modal',
title: 'Comments',
}}
/>
<Stack.Screen
name="user/[id]"
options={{ title: 'Profile' }}
/>
</Stack>
);
}
// app/(main)/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen
name="feed"
options={{
title: 'Feed',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Explore',
tabBarIcon: ({ color, size }) => (
<Ionicons name="search-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="notifications"
options={{
title: 'Notifications',
headerShown: true, // Single screen, show header
tabBarIcon: ({ color, size }) => (
<Ionicons name="notifications-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
Resetting Navigation State
Sometimes you need to reset navigation stateβafter logout, completing a flow, or handling errors. React Navigation provides several methods for this.
Reset to Initial State
import { CommonActions } from '@react-navigation/native';
import { useNavigation } from '@react-navigation/native';
function LogoutButton() {
const navigation = useNavigation();
const handleLogout = async () => {
await clearUserSession();
// Reset to login screen
navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [{ name: '(auth)' }],
})
);
};
return (
<Pressable onPress={handleLogout}>
<Text>Logout</Text>
</Pressable>
);
}
Reset Specific Navigator
import { StackActions } from '@react-navigation/native';
function OrderCompleteScreen() {
const navigation = useNavigation();
const goToOrders = () => {
// Pop all screens and push new one
navigation.dispatch(
StackActions.popToTop()
);
// Then navigate to orders
navigation.navigate('orders');
};
// Or reset the entire stack
const resetToHome = () => {
navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [
{ name: '(tabs)' },
],
})
);
};
return (/* ... */);
}
Reset Nested Navigator
// Reset a specific nested navigator
const resetHomeStack = () => {
navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [
{
name: '(tabs)',
state: {
index: 0,
routes: [
{
name: 'home',
state: {
index: 0,
routes: [{ name: 'index' }],
},
},
],
},
},
],
})
);
};
// Navigate to specific nested state
const navigateToOrderDetail = (orderId: string) => {
navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [
{
name: '(tabs)',
state: {
index: 2, // Profile tab
routes: [
{ name: 'home' },
{ name: 'search' },
{
name: 'profile',
state: {
index: 1,
routes: [
{ name: 'index' },
{ name: 'orders/[id]', params: { id: orderId } },
],
},
},
],
},
},
],
})
);
};
Using Expo Router's Replace and Dismiss
import { useRouter } from 'expo-router';
function WizardCompleteScreen() {
const router = useRouter();
// Replace current screen (no back navigation)
const finishWizard = () => {
router.replace('/dashboard');
};
// Dismiss modal and navigate
const dismissAndNavigate = () => {
router.dismiss(); // Close modal
router.push('/success');
};
// Go back multiple screens
const goBackMultiple = () => {
router.dismiss(3); // Go back 3 screens
};
return (/* ... */);
}
After Authentication Flow
// Complete auth flow pattern
function LoginScreen() {
const router = useRouter();
const handleLogin = async (credentials: Credentials) => {
try {
await login(credentials);
// Replace auth stack with main app
// This prevents going back to login
router.replace('/(main)/(tabs)');
} catch (error) {
Alert.alert('Login Failed', error.message);
}
};
return (/* ... */);
}
function OnboardingComplete() {
const router = useRouter();
const finishOnboarding = async () => {
await markOnboardingComplete();
// Reset entire navigation to start fresh
router.replace('/');
};
return (/* ... */);
}
sequenceDiagram
participant User
participant LoginScreen
participant Auth
participant Router
participant MainApp
User->>LoginScreen: Enter credentials
LoginScreen->>Auth: Authenticate
Auth-->>LoginScreen: Success
LoginScreen->>Router: router.replace('/(main)')
Note over Router: Clears auth stack
Router->>MainApp: Mount main app
Note over User,MainApp: Back button won't go to login
Hands-On Exercises
Exercise 1: Build a Three-Level Navigation
Create an app with:
- Root Stack containing auth screens and main app
- Tab Navigator with 3 tabs: Feed, Messages, Profile
- Each tab has its own Stack for drilling into content
- Proper header management (no double headers)
β Solution Structure
app/
βββ _layout.tsx # Root Stack
βββ (auth)/
β βββ _layout.tsx # Auth Stack
β βββ login.tsx
β βββ register.tsx
βββ (main)/
βββ _layout.tsx # Main Stack (headerShown: false)
βββ (tabs)/
βββ _layout.tsx # Tabs (headerShown: false per tab w/ stack)
βββ feed/
β βββ _layout.tsx # Feed Stack
β βββ index.tsx
β βββ post/[id].tsx
βββ messages/
β βββ _layout.tsx # Messages Stack
β βββ index.tsx
β βββ chat/[id].tsx
βββ profile/
βββ _layout.tsx # Profile Stack
βββ index.tsx
βββ settings.tsx
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(main)" />
</Stack>
);
}
// app/(main)/_layout.tsx
export default function MainLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
</Stack>
);
}
// app/(main)/(tabs)/_layout.tsx
export default function TabLayout() {
return (
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen name="feed" options={{ title: 'Feed' }} />
<Tabs.Screen name="messages" options={{ title: 'Messages' }} />
<Tabs.Screen name="profile" options={{ title: 'Profile' }} />
</Tabs>
);
}
// app/(main)/(tabs)/feed/_layout.tsx
export default function FeedLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Feed' }} />
<Stack.Screen name="post/[id]" options={{ title: 'Post' }} />
</Stack>
);
}
Exercise 2: Cross-Navigator Navigation
Implement the following navigation scenarios:
- From Feed tab, navigate to a user's profile (in Profile tab)
- From any screen, open a "Create Post" modal
- After creating a post, dismiss modal and navigate to the new post
β Solution
// In Feed screen - navigate to user profile
function PostCard({ post }) {
const router = useRouter();
const goToUserProfile = () => {
// Navigate to profile tab with user ID
router.push(`/(main)/(tabs)/profile?userId=${post.authorId}`);
};
return (
<Pressable onPress={goToUserProfile}>
<Text>{post.authorName}</Text>
</Pressable>
);
}
// Create Post button (accessible from anywhere)
function CreatePostButton() {
const router = useRouter();
return (
<Pressable onPress={() => router.push('/create-post')}>
<Ionicons name="add" size={24} />
</Pressable>
);
}
// Create Post modal - after success
function CreatePostScreen() {
const router = useRouter();
const handleSubmit = async (content: string) => {
const newPost = await createPost(content);
// Dismiss modal first
router.dismiss();
// Then navigate to the new post
setTimeout(() => {
router.push(`/(main)/(tabs)/feed/post/${newPost.id}`);
}, 100);
};
return (/* ... */);
}
Exercise 3: Drawer with Tabs
Create a drawer navigation that:
- Contains a tab navigator as its main content
- Has additional drawer items for Settings and About
- Includes a custom drawer header with user info
- Has a logout button that resets navigation
π‘ Hint
Structure: app/(drawer)/_layout.tsx wraps (tabs)/. Use drawerContent prop for custom content. Import DrawerContentScrollView and DrawerItemList from @react-navigation/drawer.
Exercise 4: Navigation State Reset
Implement a complete logout flow that:
- Clears user session data
- Resets all navigation state
- Navigates to login screen
- Prevents back navigation to authenticated screens
β Solution
// hooks/useLogout.ts
import { useRouter } from 'expo-router';
import { useAuth } from '@/context/AuthContext';
import AsyncStorage from '@react-native-async-storage/async-storage';
export function useLogout() {
const router = useRouter();
const { setUser } = useAuth();
const logout = async () => {
try {
// Clear all stored data
await AsyncStorage.multiRemove([
'userToken',
'userData',
'NAVIGATION_STATE',
]);
// Clear auth context
setUser(null);
// Replace entire navigation with auth
router.replace('/(auth)/login');
} catch (error) {
console.error('Logout error:', error);
}
};
return { logout };
}
// Usage in settings screen
function SettingsScreen() {
const { logout } = useLogout();
const handleLogout = () => {
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Logout', style: 'destructive', onPress: logout },
]
);
};
return (
<Pressable onPress={handleLogout}>
<Text>Logout</Text>
</Pressable>
);
}
Summary
You've mastered nested navigationβthe foundation for building complex, production-ready app architectures. You can now design and implement sophisticated navigation patterns.
π― Key Takeaways
- Nesting Hierarchy: Each
_layout.tsxcreates a navigator level; state is independent per navigator - Header Management: Hide parent headers when child navigators provide their own
- Common Architectures: Root Stack β Tabs β Stacks is the most common pattern
- Drawer Navigation: Wraps tabs for secondary navigation; requires gesture handler setup
- Cross-Navigation: Use absolute paths (
/checkout) for navigating across navigators - State Management: Use
useNavigationStateto read state,CommonActionsto reset - Auth Flows: Use
router.replace()after login/logout to prevent back navigation
ποΈ Architecture Decision Guide
Simple app (3-5 screens):
β Single Stack or Tabs
Standard app (tabs with detail views):
β Root Stack β Tabs β Stacks per tab
Complex app (auth, multiple sections):
β Root Stack β Auth Group / Main Group
β Main: Tabs β Stacks per tab
β Modals at root level
Enterprise app (drawer + tabs + deep nesting):
β Root Stack β Drawer β Tabs β Stacks
β Shared screens at drawer level
What's Next?
In the next lesson, we'll implement Authentication Flowsβprotected routes, conditional navigation, and secure patterns for handling user authentication in your app.