Skip to main content

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.

Header Anatomy Back Screen Title Edit Share headerLeft headerTitle headerRight headerStyle, headerTintColor, headerTitleStyle

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,
  }}
/>
Presentation Modes card (default) Full screen modal Card modal transparentModal Dialog formSheet (iOS) Partial sheet

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_bottom for modals and new creation flows
  • Use fade for quick transitions or overlays
  • Avoid none except 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 useFocusEffect for data that should refresh on return
  • Use useIsFocused for 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.