Module 6: Navigation with Expo Router
Stack Navigation
Building hierarchical navigation with headers, transitions, and gestures
🎯 Learning Objectives
- Configure stack navigators with custom screen options
- Customize headers with titles, buttons, and styles
- Implement different transition animations
- Handle back navigation and gestures
- Pass and receive data between stack screens
- Build common patterns: master-detail, wizards, and confirmations
- Manage screen lifecycle and focus events
Stack Fundamentals
Stack navigation is the backbone of most mobile apps. It manages a "stack" of screens where new screens are pushed on top and removed when going back. In Expo Router, you create a stack navigator using the Stack component in a _layout.tsx file.
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="index" />
<Stack.Screen name="details" />
<Stack.Screen name="settings" />
</Stack>
);
}
📖 Stack.Screen
Each Stack.Screen component registers a route with the stack navigator. The name prop corresponds to the file path (without extension). You don't have to list every screen—unlisted screens will use default options.
Screen Options
You can configure screens in three places, each with different use cases:
// 1. In the layout file (static options)
<Stack.Screen
name="profile"
options={{ title: 'My Profile' }}
/>
// 2. In the screen file (static or based on route params)
// app/profile.tsx
import { Stack } from 'expo-router';
export default function ProfileScreen() {
return (
<>
<Stack.Screen options={{ title: 'Profile Page' }} />
<View>{/* content */}</View>
</>
);
}
// 3. Dynamically based on params
// app/user/[id].tsx
import { Stack, useLocalSearchParams } from 'expo-router';
export default function UserScreen() {
const { id } = useLocalSearchParams();
return (
<>
<Stack.Screen
options={{
title: `User ${id}`,
// Can also be a function for dynamic options
}}
/>
<View>{/* content */}</View>
</>
);
}
Default Screen Options
Apply options to all screens using screenOptions:
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack
screenOptions={{
headerStyle: {
backgroundColor: '#6366f1',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
headerBackTitleVisible: false, // iOS: hide "Back" text
animation: 'slide_from_right',
}}
>
<Stack.Screen name="index" options={{ title: 'Home' }} />
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
</Stack>
);
}
Header Configuration
The header (also called navigation bar or app bar) is the top bar that shows the title and navigation controls. Expo Router provides extensive customization options.
Common Header Options
// Complete header configuration reference
const screenOptions = {
// Title
title: 'Screen Title', // Simple string title
headerTitle: 'Custom Title', // Overrides title
headerTitleAlign: 'center', // 'left' | 'center' (Android default: left)
// Visibility
headerShown: true, // Show/hide entire header
headerTransparent: false, // Transparent background
headerBlurEffect: 'regular', // iOS blur effect
// Styling
headerStyle: {
backgroundColor: '#6366f1',
height: 100, // Custom height
},
headerTintColor: '#ffffff', // Color for back button and title
headerTitleStyle: {
fontWeight: 'bold',
fontSize: 18,
},
// Shadow and border
headerShadowVisible: true, // Shadow below header
// Back button (iOS)
headerBackTitle: 'Back', // Text next to back arrow
headerBackTitleVisible: true, // Show/hide back text
headerBackTitleStyle: {
fontSize: 14,
},
// Large title (iOS)
headerLargeTitle: true, // iOS large title style
headerLargeTitleStyle: {
fontSize: 34,
},
};
Header Buttons
Add custom buttons to the header using headerLeft and headerRight:
// app/details.tsx
import { Stack, useRouter } from 'expo-router';
import { Pressable, Text, View, StyleSheet, Share } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export default function DetailsScreen() {
const router = useRouter();
const handleShare = async () => {
await Share.share({
message: 'Check out this awesome content!',
});
};
const handleEdit = () => {
router.push('/edit');
};
return (
<>
<Stack.Screen
options={{
title: 'Details',
// Custom left button (replaces back button)
headerLeft: () => (
<Pressable onPress={() => router.back()} style={styles.headerButton}>
<Ionicons name="close" size={24} color="#fff" />
</Pressable>
),
// Right buttons
headerRight: () => (
<View style={styles.headerRightContainer}>
<Pressable onPress={handleEdit} style={styles.headerButton}>
<Ionicons name="create-outline" size={22} color="#fff" />
</Pressable>
<Pressable onPress={handleShare} style={styles.headerButton}>
<Ionicons name="share-outline" size={22} color="#fff" />
</Pressable>
</View>
),
}}
/>
<View style={styles.container}>
<Text>Details Content</Text>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
headerRightContainer: {
flexDirection: 'row',
gap: 16,
},
headerButton: {
padding: 4,
},
});
⚠️ headerLeft Replaces Back Button
When you provide a custom headerLeft, it completely replaces the default back button. If you want to keep the back functionality, you need to implement it yourself using router.back() or router.canGoBack().
Dynamic Header Based on State
// app/post/[id].tsx
import { useState, useEffect } from 'react';
import { Stack, useLocalSearchParams } from 'expo-router';
import { View, Text, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export default function PostScreen() {
const { id } = useLocalSearchParams();
const [post, setPost] = useState<Post | null>(null);
const [isFavorite, setIsFavorite] = useState(false);
useEffect(() => {
// Fetch post data
fetchPost(id).then(setPost);
}, [id]);
return (
<>
<Stack.Screen
options={{
// Dynamic title from fetched data
title: post?.title ?? 'Loading...',
// Dynamic right button based on state
headerRight: () => (
<Pressable onPress={() => setIsFavorite(!isFavorite)}>
<Ionicons
name={isFavorite ? 'heart' : 'heart-outline'}
size={24}
color={isFavorite ? '#ef4444' : '#fff'}
/>
</Pressable>
),
}}
/>
<View>
{post ? <Text>{post.content}</Text> : <Text>Loading...</Text>}
</View>
</>
);
}
Custom Headers
Sometimes the default header options aren't enough. You can replace the entire header with a custom component.
// components/CustomHeader.tsx
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
interface CustomHeaderProps {
title: string;
showBack?: boolean;
rightElement?: React.ReactNode;
}
export function CustomHeader({
title,
showBack = true,
rightElement
}: CustomHeaderProps) {
const router = useRouter();
const insets = useSafeAreaInsets();
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.content}>
{/* Left section */}
<View style={styles.leftSection}>
{showBack && (
<Pressable onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color="#333" />
</Pressable>
)}
</View>
{/* Title */}
<View style={styles.titleSection}>
<Text style={styles.title} numberOfLines={1}>{title}</Text>
</View>
{/* Right section */}
<View style={styles.rightSection}>
{rightElement}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
content: {
flexDirection: 'row',
alignItems: 'center',
height: 56,
paddingHorizontal: 4,
},
leftSection: {
width: 60,
alignItems: 'flex-start',
},
titleSection: {
flex: 1,
alignItems: 'center',
},
rightSection: {
width: 60,
alignItems: 'flex-end',
paddingRight: 8,
},
backButton: {
padding: 12,
},
title: {
fontSize: 17,
fontWeight: '600',
color: '#111',
},
});
Use your custom header by hiding the default and rendering your own:
// app/custom-header-demo.tsx
import { View, Text, StyleSheet } from 'react-native';
import { Stack } from 'expo-router';
import { CustomHeader } from '@/components/CustomHeader';
import { Ionicons } from '@expo/vector-icons';
export default function CustomHeaderDemo() {
return (
<>
{/* Hide the default header */}
<Stack.Screen options={{ headerShown: false }} />
{/* Render custom header */}
<CustomHeader
title="Custom Header"
rightElement={
<Ionicons name="settings-outline" size={22} color="#333" />
}
/>
<View style={styles.content}>
<Text>Screen content here</Text>
</View>
</>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
padding: 20,
},
});
💡 When to Use Custom Headers
- Complex layouts (search bars, segmented controls)
- Animated headers (collapsing, parallax)
- Headers with images or gradients
- Unique branding requirements
- Headers with progress indicators or tabs
Search Header Example
// components/SearchHeader.tsx
import { useState } from 'react';
import { View, TextInput, Pressable, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
interface SearchHeaderProps {
onSearch: (query: string) => void;
placeholder?: string;
}
export function SearchHeader({ onSearch, placeholder = 'Search...' }: SearchHeaderProps) {
const [query, setQuery] = useState('');
const router = useRouter();
const insets = useSafeAreaInsets();
const handleSubmit = () => {
onSearch(query);
};
const handleClear = () => {
setQuery('');
onSearch('');
};
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<Pressable onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color="#333" />
</Pressable>
<View style={styles.searchContainer}>
<Ionicons name="search" size={20} color="#9ca3af" style={styles.searchIcon} />
<TextInput
style={styles.input}
value={query}
onChangeText={setQuery}
onSubmitEditing={handleSubmit}
placeholder={placeholder}
placeholderTextColor="#9ca3af"
returnKeyType="search"
autoFocus
/>
{query.length > 0 && (
<Pressable onPress={handleClear} style={styles.clearButton}>
<Ionicons name="close-circle" size={20} color="#9ca3af" />
</Pressable>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#fff',
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 4,
paddingBottom: 8,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
backButton: {
padding: 12,
},
searchContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f3f4f6',
borderRadius: 10,
marginRight: 12,
paddingHorizontal: 12,
height: 40,
},
searchIcon: {
marginRight: 8,
},
input: {
flex: 1,
fontSize: 16,
color: '#111',
},
clearButton: {
padding: 4,
},
});
Transitions and Animations
Expo Router (via React Navigation) provides several built-in transition animations. You can also create custom animations for unique effects.
Built-in Animations
// Available animation presets
const animationOptions = {
// Standard animations
animation: 'default', // Platform default
animation: 'fade', // Crossfade
animation: 'fade_from_bottom', // Fade while sliding up
animation: 'slide_from_right', // Slide in from right (iOS default)
animation: 'slide_from_left', // Slide in from left
animation: 'slide_from_bottom', // Slide up (modal-like)
animation: 'none', // No animation
// iOS-specific
animation: 'ios', // Native iOS animation
// Flip animations
animation: 'flip', // 3D flip
};
// Apply to specific screen
<Stack.Screen
name="modal-screen"
options={{
animation: 'slide_from_bottom',
presentation: 'modal',
}}
/>
// Apply to all screens
<Stack
screenOptions={{
animation: 'slide_from_right',
}}
>
Presentation Modes
The presentation option changes how screens are displayed:
// Presentation options
const presentationOptions = {
presentation: 'card', // Default stack card
presentation: 'modal', // Modal presentation
presentation: 'transparentModal', // Modal with transparent background
presentation: 'containedModal', // Modal contained within parent
presentation: 'containedTransparentModal',
presentation: 'fullScreenModal', // iOS full screen modal
presentation: 'formSheet', // iOS form sheet style
};
// Example: Modal screen
<Stack.Screen
name="create-post"
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
headerShown: true,
title: 'Create Post',
}}
/>
// Example: Transparent overlay
<Stack.Screen
name="overlay"
options={{
presentation: 'transparentModal',
animation: 'fade',
headerShown: false,
}}
/>
Custom Transition Animation
import { Stack } from 'expo-router';
import { TransitionPresets } from '@react-navigation/stack';
// Using preset transitions
<Stack
screenOptions={{
...TransitionPresets.SlideFromRightIOS,
}}
/>
// Custom animation config
<Stack.Screen
name="custom-transition"
options={{
animationTypeForReplace: 'push', // or 'pop'
gestureEnabled: true,
gestureDirection: 'horizontal',
transitionSpec: {
open: {
animation: 'spring',
config: {
stiffness: 1000,
damping: 500,
mass: 3,
overshootClamping: true,
restDisplacementThreshold: 0.01,
restSpeedThreshold: 0.01,
},
},
close: {
animation: 'timing',
config: {
duration: 200,
},
},
},
}}
/>
✅ Animation Best Practices
- Use platform defaults for consistency with OS conventions
- Use
slide_from_bottomfor modals and new creation flows - Use
fadefor quick transitions or overlays - Avoid
noneexcept for immediate transitions after login/logout - Test on real devices—animations feel different than on simulators
Gestures and Back Handling
Mobile navigation is gesture-driven. Users expect to swipe to go back on iOS and use the system back button on Android.
Gesture Configuration
// Gesture options
<Stack.Screen
name="screen"
options={{
// Enable/disable swipe back gesture
gestureEnabled: true, // Default: true on iOS
// Gesture direction
gestureDirection: 'horizontal', // 'horizontal' | 'vertical'
// How far user must swipe to trigger navigation
gestureResponseDistance: 50, // pixels from edge
// Full screen gesture (iOS 13+)
fullScreenGestureEnabled: true, // Swipe from anywhere, not just edge
}}
/>
Preventing Back Navigation
Sometimes you need to prevent users from going back (e.g., during form submission or unsaved changes):
// Method 1: Disable gesture
<Stack.Screen
name="checkout"
options={{
gestureEnabled: false,
headerBackVisible: false, // Hide back button
}}
/>
// Method 2: Using beforeRemove event (recommended)
import { useNavigation } from '@react-navigation/native';
import { useEffect, useState } from 'react';
import { Alert } from 'react-native';
export default function FormScreen() {
const navigation = useNavigation();
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
if (!hasUnsavedChanges) {
// No unsaved changes, allow navigation
return;
}
// Prevent default behavior (leaving the screen)
e.preventDefault();
// Show confirmation dialog
Alert.alert(
'Discard changes?',
'You have unsaved changes. Are you sure you want to leave?',
[
{ text: "Stay", style: 'cancel' },
{
text: 'Leave',
style: 'destructive',
onPress: () => navigation.dispatch(e.data.action),
},
]
);
});
return unsubscribe;
}, [navigation, hasUnsavedChanges]);
return (
<View>
<TextInput
onChangeText={(text) => setHasUnsavedChanges(text.length > 0)}
placeholder="Type something..."
/>
</View>
);
}
Android Back Button Handling
import { useCallback, useEffect } from 'react';
import { BackHandler, Platform } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
export default function CustomBackScreen() {
// Method 1: Using BackHandler directly
useEffect(() => {
if (Platform.OS !== 'android') return;
const backHandler = BackHandler.addEventListener(
'hardwareBackPress',
() => {
// Return true to prevent default back behavior
// Return false to allow default behavior
console.log('Back button pressed!');
return false; // Allow default
}
);
return () => backHandler.remove();
}, []);
// Method 2: Only when screen is focused
useFocusEffect(
useCallback(() => {
const backHandler = BackHandler.addEventListener(
'hardwareBackPress',
() => {
// Custom back behavior when this screen is focused
return false;
}
);
return () => backHandler.remove();
}, [])
);
return <View>{/* content */}</View>;
}
flowchart TD
A[User triggers back] --> B{Platform?}
B -->|iOS| C[Swipe gesture]
B -->|Android| D[System back button]
C --> E{gestureEnabled?}
E -->|true| F[Animate back]
E -->|false| G[Block gesture]
D --> H{BackHandler?}
H -->|Not handled| F
H -->|Handled + return true| I[Custom action]
H -->|Handled + return false| F
F --> J{beforeRemove listener?}
J -->|No| K[Pop screen]
J -->|Yes + prevented| L[Stay on screen]
J -->|Yes + allowed| K
style K fill:#e8f5e9
style L fill:#fff3cd
style I fill:#e3f2fd
Passing Data Between Screens
There are several ways to pass data between screens in a stack. Choose the method that best fits your use case.
Method 1: Route Parameters
The simplest approach—pass data through the URL:
// Sending data
import { Link, useRouter } from 'expo-router';
// Using Link
<Link href="/product/123">View Product</Link>
<Link href="/product/123?color=blue&size=large">View Blue Large</Link>
// Using router
const router = useRouter();
router.push('/product/123');
router.push({
pathname: '/product/[id]',
params: { id: '123', color: 'blue', size: 'large' },
});
// Receiving data
import { useLocalSearchParams } from 'expo-router';
export default function ProductScreen() {
const { id, color, size } = useLocalSearchParams<{
id: string;
color?: string;
size?: string;
}>>();
return (
<View>
<Text>Product: {id}</Text>
<Text>Color: {color ?? 'default'}</Text>
<Text>Size: {size ?? 'medium'}</Text>
</View>
);
}
⚠️ Route Params Are Strings
All route parameters are strings (or arrays of strings for catch-all routes). If you need to pass numbers, booleans, or objects, you'll need to serialize/deserialize them or use a different method.
Method 2: Passing Objects via JSON
// Sending complex data
const item = {
id: 123,
name: 'Wireless Headphones',
price: 99.99,
inStock: true,
};
router.push({
pathname: '/checkout',
params: { item: JSON.stringify(item) },
});
// Receiving
export default function CheckoutScreen() {
const { item: itemString } = useLocalSearchParams<{ item: string }>();
const item = itemString ? JSON.parse(itemString) : null;
if (!item) return <Text>No item provided</Text>;
return (
<View>
<Text>{item.name}</Text>
<Text>${item.price}</Text>
</View>
);
}
Method 3: Global State (Context/Zustand)
For complex data or data that multiple screens need:
// context/CartContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartContextType {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: number;
}
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const addItem = (item: CartItem) => {
setItems(prev => [...prev, item]);
};
const removeItem = (id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
};
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return (
<CartContext.Provider value={{ items, addItem, removeItem, total }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error('useCart must be used within CartProvider');
return context;
}
// app/_layout.tsx - Wrap your app
import { CartProvider } from '@/context/CartContext';
export default function RootLayout() {
return (
<CartProvider>
<Stack>{/* screens */}</Stack>
</CartProvider>
);
}
// Any screen can now access cart data
import { useCart } from '@/context/CartContext';
export default function ProductScreen() {
const { addItem } = useCart();
return (
<Pressable onPress={() => addItem({ id: '1', name: 'Item', price: 10, quantity: 1 })}>
<Text>Add to Cart</Text>
</Pressable>
);
}
Method 4: Returning Data to Previous Screen
Sometimes you need to return data from a screen (like a picker or form):
// Screen A: Opens picker
import { useState, useCallback } from 'react';
import { useFocusEffect } from '@react-navigation/native';
import AsyncStorage from '@react-native-async-storage/async-storage';
export default function FormScreen() {
const [selectedColor, setSelectedColor] = useState('');
const router = useRouter();
// Check for returned data when screen focuses
useFocusEffect(
useCallback(() => {
async function checkSelection() {
const color = await AsyncStorage.getItem('selectedColor');
if (color) {
setSelectedColor(color);
await AsyncStorage.removeItem('selectedColor'); // Clean up
}
}
checkSelection();
}, [])
);
return (
<View>
<Text>Selected: {selectedColor || 'None'}</Text>
<Pressable onPress={() => router.push('/color-picker')}>
<Text>Choose Color</Text>
</Pressable>
</View>
);
}
// Screen B: Color picker that returns data
export default function ColorPickerScreen() {
const router = useRouter();
const selectColor = async (color: string) => {
await AsyncStorage.setItem('selectedColor', color);
router.back();
};
return (
<View>
{['Red', 'Blue', 'Green'].map(color => (
<Pressable key={color} onPress={() => selectColor(color)}>
<Text>{color}</Text>
</Pressable>
))}
</View>
);
}
📋 Data Passing Methods Summary
| Method | Best For | Limitations |
|---|---|---|
| Route params | IDs, simple values, filters | Strings only, visible in URL |
| JSON in params | Small objects | URL length limits, ugly URLs |
| Context/State | Shared state, complex data | More setup required |
| AsyncStorage | Returning data to prev screen | Async, requires cleanup |
Screen Lifecycle and Focus
Unlike web pages, stack screens stay mounted when you navigate away. This means useEffect doesn't run again when returning to a screen. You need focus-aware patterns.
Focus and Blur Events
import { useCallback } from 'react';
import { useFocusEffect } from '@react-navigation/native';
export default function ListScreen() {
// Runs every time screen comes into focus
useFocusEffect(
useCallback(() => {
console.log('Screen focused!');
fetchData(); // Refresh data when screen focuses
return () => {
console.log('Screen blurred!');
// Cleanup when leaving
};
}, [])
);
return <View>{/* content */}</View>;
}
useIsFocused Hook
import { useIsFocused } from '@react-navigation/native';
export default function CameraScreen() {
const isFocused = useIsFocused();
// Only render camera when screen is focused
// This prevents camera from running in background
return (
<View style={{ flex: 1 }}>
{isFocused ? (
<Camera style={{ flex: 1 }} />
) : (
<View style={{ flex: 1, backgroundColor: 'black' }} />
)}
</View>
);
}
Common Lifecycle Patterns
// Pattern 1: Refresh data on focus
function ProductListScreen() {
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const fetchProducts = async () => {
setIsLoading(true);
const data = await api.getProducts();
setProducts(data);
setIsLoading(false);
};
// Fetch on mount
useEffect(() => {
fetchProducts();
}, []);
// Refresh on focus (if needed)
useFocusEffect(
useCallback(() => {
// Only refresh if we have stale data indicator
// Or always refresh for real-time data
fetchProducts();
}, [])
);
return <FlatList data={products} /* ... */ />;
}
// Pattern 2: Pause/resume subscriptions
function NotificationsScreen() {
const isFocused = useIsFocused();
const [notifications, setNotifications] = useState([]);
useEffect(() => {
if (!isFocused) return;
// Start subscription only when focused
const unsubscribe = subscribeToNotifications((notification) => {
setNotifications(prev => [notification, ...prev]);
});
return unsubscribe;
}, [isFocused]);
return <FlatList data={notifications} /* ... */ />;
}
// Pattern 3: Analytics screen tracking
function AnalyticsWrapper({ children, screenName }) {
useFocusEffect(
useCallback(() => {
analytics.trackScreenView(screenName);
}, [screenName])
);
return children;
}
sequenceDiagram
participant User
participant ScreenA
participant ScreenB
Note over ScreenA: useEffect runs (mount)
Note over ScreenA: useFocusEffect runs (focus)
User->>ScreenB: Navigate to B
Note over ScreenA: useFocusEffect cleanup (blur)
Note over ScreenA: Screen stays MOUNTED
Note over ScreenB: useEffect runs (mount)
Note over ScreenB: useFocusEffect runs (focus)
User->>ScreenA: Go back
Note over ScreenB: useFocusEffect cleanup (blur)
Note over ScreenB: useEffect cleanup (unmount)
Note over ScreenB: Screen UNMOUNTS
Note over ScreenA: useFocusEffect runs again (focus)
Note over ScreenA: useEffect does NOT run (still mounted)
✅ Lifecycle Best Practices
- Use
useFocusEffectfor data that should refresh on return - Use
useIsFocusedfor components that should pause (camera, video) - Clean up subscriptions in the return function
- Be mindful of memory—screens stay mounted in the stack
Common Stack Patterns
Master-Detail Pattern
The most common stack pattern—a list that opens detail views:
// app/contacts/_layout.tsx
import { Stack } from 'expo-router';
export default function ContactsLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{ title: 'Contacts' }}
/>
<Stack.Screen
name="[id]"
options={{ title: 'Contact Details' }}
/>
<Stack.Screen
name="edit/[id]"
options={{
title: 'Edit Contact',
presentation: 'modal',
}}
/>
</Stack>
);
}
// app/contacts/index.tsx (List)
export default function ContactsList() {
const contacts = useContacts();
return (
<FlatList
data={contacts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Link href={`/contacts/${item.id}`} asChild>
<Pressable style={styles.row}>
<Text>{item.name}</Text>
<Ionicons name="chevron-forward" size={20} color="#ccc" />
</Pressable>
</Link>
)}
/>
);
}
// app/contacts/[id].tsx (Detail)
export default function ContactDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const contact = useContact(id);
const router = useRouter();
return (
<>
<Stack.Screen
options={{
title: contact?.name ?? 'Contact',
headerRight: () => (
<Pressable onPress={() => router.push(`/contacts/edit/${id}`)}>
<Text style={{ color: '#007AFF' }}>Edit</Text>
</Pressable>
),
}}
/>
<View style={styles.container}>
<Text style={styles.name}>{contact?.name}</Text>
<Text>{contact?.phone}</Text>
<Text>{contact?.email}</Text>
</View>
</>
);
}
Wizard/Multi-Step Form Pattern
// app/onboarding/_layout.tsx
import { Stack } from 'expo-router';
export default function OnboardingLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
gestureEnabled: false, // Prevent swipe back
animation: 'slide_from_right',
}}
/>
);
}
// app/onboarding/index.tsx (redirects to step1)
import { Redirect } from 'expo-router';
export default () => <Redirect href="/onboarding/step1" />;
// app/onboarding/step1.tsx
export default function Step1() {
const router = useRouter();
const [name, setName] = useState('');
const handleNext = () => {
if (name.trim()) {
router.push({
pathname: '/onboarding/step2',
params: { name },
});
}
};
return (
<SafeAreaView style={styles.container}>
<ProgressBar step={1} total={3} />
<Text style={styles.title}>What's your name?</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Enter your name"
style={styles.input}
/>
<Pressable
style={[styles.button, !name.trim() && styles.buttonDisabled]}
onPress={handleNext}
disabled={!name.trim()}
>
<Text style={styles.buttonText}>Continue</Text>
</Pressable>
</SafeAreaView>
);
}
// app/onboarding/step2.tsx, step3.tsx follow same pattern
// Final step uses router.replace('/home') to clear onboarding stack
Confirmation Modal Pattern
// app/delete-confirm.tsx
import { Stack, useRouter, useLocalSearchParams } from 'expo-router';
import { View, Text, Pressable, StyleSheet } from 'react-native';
export default function DeleteConfirmModal() {
const router = useRouter();
const { itemId, itemName } = useLocalSearchParams<{
itemId: string;
itemName: string;
}>>();
const handleDelete = async () => {
await deleteItem(itemId);
router.back(); // Close modal
// Or router.replace('/items') to go to list
};
return (
<>
<Stack.Screen
options={{
presentation: 'transparentModal',
animation: 'fade',
headerShown: false,
}}
/>
<View style={styles.overlay}>
<View style={styles.modal}>
<Text style={styles.title}>Delete {itemName}?</Text>
<Text style={styles.message}>
This action cannot be undone.
</Text>
<View style={styles.buttons}>
<Pressable
style={[styles.button, styles.cancelButton]}
onPress={() => router.back()}
>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.button, styles.deleteButton]}
onPress={handleDelete}
>
<Text style={styles.deleteText}>Delete</Text>
</Pressable>
</View>
</View>
</View>
</>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
modal: {
backgroundColor: 'white',
borderRadius: 16,
padding: 24,
width: '80%',
maxWidth: 320,
},
title: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 8,
},
message: {
color: '#666',
marginBottom: 24,
},
buttons: {
flexDirection: 'row',
gap: 12,
},
button: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
},
cancelButton: {
backgroundColor: '#f3f4f6',
},
cancelText: {
color: '#333',
fontWeight: '600',
},
deleteButton: {
backgroundColor: '#ef4444',
},
deleteText: {
color: 'white',
fontWeight: '600',
},
});
// Usage from any screen:
router.push({
pathname: '/delete-confirm',
params: { itemId: '123', itemName: 'My Document' },
});
Hands-On Exercises
Exercise 1: Custom Header with Actions
Create a notes app with:
- A list screen showing notes
- A detail screen with Edit and Delete buttons in the header
- Delete should show a confirmation alert
- Edit should navigate to an edit screen
✅ Solution
// app/notes/[id].tsx
import { Stack, useRouter, useLocalSearchParams } from 'expo-router';
import { View, Text, Alert, Pressable, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export default function NoteDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const note = useNote(id); // Your data hook
const handleDelete = () => {
Alert.alert(
'Delete Note',
'Are you sure you want to delete this note?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
await deleteNote(id);
router.back();
},
},
]
);
};
return (
<>
<Stack.Screen
options={{
title: note?.title ?? 'Note',
headerRight: () => (
<View style={styles.headerButtons}>
<Pressable onPress={() => router.push(`/notes/edit/${id}`)}>
<Ionicons name="create-outline" size={22} color="#007AFF" />
</Pressable>
<Pressable onPress={handleDelete}>
<Ionicons name="trash-outline" size={22} color="#ef4444" />
</Pressable>
</View>
),
}}
/>
<View style={styles.container}>
<Text style={styles.content}>{note?.content}</Text>
</View>
</>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
headerButtons: { flexDirection: 'row', gap: 20 },
content: { fontSize: 16, lineHeight: 24 },
});
Exercise 2: Unsaved Changes Warning
Create a form screen that:
- Tracks if the user has made changes
- Shows a warning when trying to leave with unsaved changes
- Allows leaving if changes are saved or discarded
✅ Solution
import { useState, useEffect } from 'react';
import { View, Text, TextInput, Pressable, Alert, StyleSheet } from 'react-native';
import { Stack, useRouter } from 'expo-router';
import { useNavigation } from '@react-navigation/native';
export default function EditProfileScreen() {
const navigation = useNavigation();
const router = useRouter();
const [name, setName] = useState('');
const [originalName, setOriginalName] = useState('');
const [isSaving, setIsSaving] = useState(false);
const hasChanges = name !== originalName;
useEffect(() => {
// Load initial data
const loadProfile = async () => {
const profile = await fetchProfile();
setName(profile.name);
setOriginalName(profile.name);
};
loadProfile();
}, []);
// Prevent leaving with unsaved changes
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
if (!hasChanges || isSaving) return;
e.preventDefault();
Alert.alert(
'Discard changes?',
'You have unsaved changes. Do you want to discard them?',
[
{ text: 'Keep Editing', style: 'cancel' },
{
text: 'Discard',
style: 'destructive',
onPress: () => navigation.dispatch(e.data.action),
},
]
);
});
return unsubscribe;
}, [navigation, hasChanges, isSaving]);
const handleSave = async () => {
setIsSaving(true);
await saveProfile({ name });
setOriginalName(name);
setIsSaving(false);
router.back();
};
return (
<>
<Stack.Screen
options={{
title: 'Edit Profile',
headerRight: () => (
<Pressable onPress={handleSave} disabled={!hasChanges || isSaving}>
<Text style={{
color: hasChanges && !isSaving ? '#007AFF' : '#ccc',
fontWeight: '600',
}}>
{isSaving ? 'Saving...' : 'Save'}
</Text>
</Pressable>
),
}}
/>
<View style={styles.container}>
<Text style={styles.label}>Name</Text>
<TextInput
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Your name"
/>
</View>
</>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
label: { fontSize: 14, color: '#666', marginBottom: 8 },
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
fontSize: 16
},
});
Exercise 3: Multi-Step Wizard
Build a 3-step signup wizard with:
- Step 1: Email input
- Step 2: Password input
- Step 3: Profile details (name, bio)
- Progress indicator showing current step
- Data passed between steps
💡 Hint
Create a route group (signup)/ with step1.tsx, step2.tsx, step3.tsx. Pass data via route params or a context provider. Use router.replace on the final step to clear the signup stack.
Summary
You've mastered stack navigation—the foundation of mobile app navigation. You can now build sophisticated navigation experiences with custom headers, transitions, and data flow.
🎯 Key Takeaways
- Screen options: Configure in layout, screen file, or dynamically
- Headers: Customize with headerLeft, headerRight, headerTitle, or use custom headers
- Animations: Use built-in presets (slide, fade, modal) or create custom transitions
- Gestures: Control swipe-back with gestureEnabled, handle Android back with BackHandler
- Data passing: Route params for simple data, context for complex shared state
- Lifecycle: Use useFocusEffect for focus-aware effects, useIsFocused for conditional rendering
- Patterns: Master-detail, wizards, and confirmation modals are your building blocks
🚀 Stack Navigation Checklist
✓ Layout defines Stack navigator
✓ Screen options configured appropriately
✓ Headers styled and functional
✓ Transitions match user expectations
✓ Back gestures work correctly
✓ Data flows between screens
✓ Focus effects handle refresh logic
✓ Unsaved changes protected where needed
What's Next?
In the next lesson, we'll add Tab Navigation to create parallel navigation structures. You'll learn how to combine tabs with stacks for the navigation architecture used by most production apps.