Skip to main content

Module 6: Navigation with Expo Router

Advanced Patterns

Master complex navigation with custom transitions and optimization

🎯 Learning Objectives

  • Create custom screen transitions with Reanimated
  • Implement shared element transitions between screens
  • Build advanced modal patterns and bottom sheets
  • Optimize navigation performance for large apps
  • Handle complex navigation state scenarios
  • Create conditional navigation flows
  • Implement navigation analytics and logging

Custom Screen Transitions

While React Navigation provides built-in transitions, custom animations can make your app feel unique and polished. Use Reanimated for smooth, native-performance animations.

Built-in Animation Options

// app/_layout.tsx
import { Stack } from 'expo-router';

export default function Layout() {
  return (
    <Stack
      screenOptions={{
        // Built-in animations
        animation: 'slide_from_right', // default iOS
        // animation: 'slide_from_bottom',
        // animation: 'fade',
        // animation: 'fade_from_bottom',
        // animation: 'flip',
        // animation: 'simple_push',
        // animation: 'none',
        
        // iOS-specific
        animationTypeForReplace: 'push', // or 'pop'
        
        // Timing
        animationDuration: 350,
      }}
    >
      <Stack.Screen name="index" />
      <Stack.Screen 
        name="detail" 
        options={{
          animation: 'fade_from_bottom',
        }}
      />
    </Stack>
  );
}

Custom Transition with Reanimated

# Install Reanimated
npx expo install react-native-reanimated
// Custom fade and scale transition
import { Stack } from 'expo-router';
import { 
  StackCardStyleInterpolator,
  StackCardInterpolationProps 
} from '@react-navigation/stack';
import { Animated } from 'react-native';

// Custom interpolator for fade + scale effect
const forFadeScale: StackCardStyleInterpolator = ({
  current,
  next,
  inverted,
  layouts: { screen },
}: StackCardInterpolationProps) => {
  const progress = Animated.add(
    current.progress.interpolate({
      inputRange: [0, 1],
      outputRange: [0, 1],
      extrapolate: 'clamp',
    }),
    next
      ? next.progress.interpolate({
          inputRange: [0, 1],
          outputRange: [0, 1],
          extrapolate: 'clamp',
        })
      : 0
  );

  return {
    cardStyle: {
      opacity: current.progress.interpolate({
        inputRange: [0, 0.5, 0.9, 1],
        outputRange: [0, 0.25, 0.7, 1],
      }),
      transform: [
        {
          scale: current.progress.interpolate({
            inputRange: [0, 1],
            outputRange: [0.9, 1],
            extrapolate: 'clamp',
          }),
        },
      ],
    },
    overlayStyle: {
      opacity: current.progress.interpolate({
        inputRange: [0, 1],
        outputRange: [0, 0.5],
        extrapolate: 'clamp',
      }),
    },
  };
};

export default function Layout() {
  return (
    <Stack
      screenOptions={{
        cardStyleInterpolator: forFadeScale,
        gestureEnabled: true,
        gestureDirection: 'horizontal',
      }}
    >
      <Stack.Screen name="index" />
    </Stack>
  );
}

Slide from Different Directions

// Custom slide transitions
import { TransitionPresets } from '@react-navigation/stack';

// Slide from left (reverse of default)
const slideFromLeft: StackCardStyleInterpolator = ({ current, layouts }) => ({
  cardStyle: {
    transform: [
      {
        translateX: current.progress.interpolate({
          inputRange: [0, 1],
          outputRange: [-layouts.screen.width, 0],
        }),
      },
    ],
  },
});

// Slide from top
const slideFromTop: StackCardStyleInterpolator = ({ current, layouts }) => ({
  cardStyle: {
    transform: [
      {
        translateY: current.progress.interpolate({
          inputRange: [0, 1],
          outputRange: [-layouts.screen.height, 0],
        }),
      },
    ],
  },
});

// Usage per screen
<Stack.Screen
  name="notifications"
  options={{
    cardStyleInterpolator: slideFromTop,
    gestureDirection: 'vertical',
  }}
/>

Platform-Specific Transitions

import { Platform } from 'react-native';
import { TransitionPresets } from '@react-navigation/stack';

const screenOptions = Platform.select({
  ios: {
    ...TransitionPresets.SlideFromRightIOS,
    gestureEnabled: true,
    gestureResponseDistance: 50,
  },
  android: {
    ...TransitionPresets.FadeFromBottomAndroid,
    gestureEnabled: false,
  },
  default: {
    animation: 'fade',
  },
});

export default function Layout() {
  return (
    <Stack screenOptions={screenOptions}>
      {/* screens */}
    </Stack>
  );
}
Common Screen Transitions Slide Right Fade Scale Modal

Shared Element Transitions

Shared element transitions create visual continuity by animating an element from one screen to another. This is perfect for image galleries, product lists, and profile views.

Using react-native-shared-element

# Install shared element library
npx expo install react-native-shared-element
npm install react-navigation-shared-element
// Note: As of 2024, react-native-shared-element has limited 
// compatibility with Expo Router. Here's an alternative approach
// using Reanimated for a similar effect.

// components/SharedImage.tsx
import { useEffect } from 'react';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  interpolate,
  Extrapolation,
} from 'react-native-reanimated';
import { Image, StyleSheet, Dimensions } from 'react-native';

const { width: SCREEN_WIDTH } = Dimensions.get('window');

interface SharedImageProps {
  source: { uri: string };
  isDetail?: boolean;
  thumbnailSize?: number;
}

export function SharedImage({ 
  source, 
  isDetail = false,
  thumbnailSize = 100,
}: SharedImageProps) {
  const progress = useSharedValue(isDetail ? 0 : 1);

  useEffect(() => {
    progress.value = withTiming(isDetail ? 1 : 0, { duration: 300 });
  }, [isDetail]);

  const animatedStyle = useAnimatedStyle(() => {
    const size = interpolate(
      progress.value,
      [0, 1],
      [thumbnailSize, SCREEN_WIDTH],
      Extrapolation.CLAMP
    );

    const borderRadius = interpolate(
      progress.value,
      [0, 1],
      [8, 0],
      Extrapolation.CLAMP
    );

    return {
      width: size,
      height: size,
      borderRadius,
    };
  });

  return (
    <Animated.Image
      source={source}
      style={[styles.image, animatedStyle]}
      resizeMode="cover"
    />
  );
}

const styles = StyleSheet.create({
  image: {
    backgroundColor: '#f0f0f0',
  },
});

Hero Transition Pattern

// A complete hero transition using layout animations
// app/products/index.tsx
import { View, FlatList, Pressable, StyleSheet } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { Link } from 'expo-router';

interface Product {
  id: string;
  title: string;
  image: string;
}

export default function ProductList() {
  return (
    <FlatList
      data={products}
      numColumns={2}
      keyExtractor={(item) => item.id}
      renderItem={({ item, index }) => (
        <Link href={`/products/${item.id}`} asChild>
          <Pressable style={styles.card}>
            <Animated.Image
              entering={FadeIn.delay(index * 50)}
              source={{ uri: item.image }}
              style={styles.thumbnail}
              sharedTransitionTag={`product-${item.id}`}
            />
            <Animated.Text 
              entering={FadeIn.delay(index * 50 + 100)}
              style={styles.title}
            >
              {item.title}
            </Animated.Text>
          </Pressable>
        </Link>
      )}
    />
  );
}

// app/products/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';

export default function ProductDetail() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const product = useProduct(id);

  return (
    <View style={styles.container}>
      <Animated.Image
        source={{ uri: product.image }}
        style={styles.heroImage}
        sharedTransitionTag={`product-${id}`}
      />
      <Animated.View 
        entering={FadeInDown.delay(200).springify()}
        style={styles.content}
      >
        <Text style={styles.title}>{product.title}</Text>
        <Text style={styles.description}>{product.description}</Text>
      </Animated.View>
    </View>
  );
}

Native Shared Element Transitions (SDK 50+)

// Expo SDK 50+ supports native shared transitions via Reanimated
import Animated from 'react-native-reanimated';

// In list screen
<Animated.Image
  source={{ uri: item.image }}
  style={styles.thumbnail}
  sharedTransitionTag={`image-${item.id}`} // Unique tag
/>

// In detail screen - same tag creates the transition
<Animated.Image
  source={{ uri: product.image }}
  style={styles.fullImage}
  sharedTransitionTag={`image-${product.id}`} // Matching tag
/>

// The transition happens automatically when navigating!

βœ… Shared Transition Tips

  • Use unique, consistent tags between screens
  • Keep the same aspect ratio for smoothest transitions
  • Avoid transforming other properties during the transition
  • Test on real devicesβ€”simulators may show different performance

Bottom Sheet Navigation

Bottom sheets provide a native-feeling way to present content that can be dismissed with a swipe gesture.

Using @gorhom/bottom-sheet

# Install bottom sheet library
npx expo install @gorhom/bottom-sheet react-native-gesture-handler react-native-reanimated
// components/NavigableBottomSheet.tsx
import { forwardRef, useCallback, useMemo } from 'react';
import { View, StyleSheet } from 'react-native';
import BottomSheet, {
  BottomSheetBackdrop,
  BottomSheetView,
} from '@gorhom/bottom-sheet';
import { useRouter } from 'expo-router';

interface NavigableBottomSheetProps {
  children: React.ReactNode;
  snapPoints?: (string | number)[];
  onDismiss?: () => void;
}

export const NavigableBottomSheet = forwardRef<
  BottomSheet,
  NavigableBottomSheetProps
>(({ children, snapPoints: customSnapPoints, onDismiss }, ref) => {
  const router = useRouter();
  const snapPoints = useMemo(
    () => customSnapPoints || ['25%', '50%', '90%'],
    [customSnapPoints]
  );

  const handleSheetChanges = useCallback((index: number) => {
    if (index === -1) {
      // Sheet closed
      onDismiss?.();
    }
  }, [onDismiss]);

  const renderBackdrop = useCallback(
    (props: any) => (
      <BottomSheetBackdrop
        {...props}
        disappearsOnIndex={-1}
        appearsOnIndex={0}
        opacity={0.5}
      />
    ),
    []
  );

  return (
    <BottomSheet
      ref={ref}
      index={0}
      snapPoints={snapPoints}
      onChange={handleSheetChanges}
      backdropComponent={renderBackdrop}
      enablePanDownToClose
      handleIndicatorStyle={styles.indicator}
    >
      <BottomSheetView style={styles.content}>
        {children}
      </BottomSheetView>
    </BottomSheet>
  );
});

const styles = StyleSheet.create({
  indicator: {
    backgroundColor: '#ccc',
    width: 40,
  },
  content: {
    flex: 1,
    padding: 16,
  },
});

Bottom Sheet with Navigation

// Using bottom sheet as a navigation destination
// app/sheets/actions.tsx
import { useRef, useEffect } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import BottomSheet from '@gorhom/bottom-sheet';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

const actions = [
  { id: 'share', icon: 'share-outline', label: 'Share', route: '/share' },
  { id: 'edit', icon: 'create-outline', label: 'Edit', route: '/edit' },
  { id: 'delete', icon: 'trash-outline', label: 'Delete', route: '/confirm-delete' },
  { id: 'report', icon: 'flag-outline', label: 'Report', route: '/report' },
];

export default function ActionsSheet() {
  const router = useRouter();
  const { itemId } = useLocalSearchParams<{ itemId: string }>();
  const bottomSheetRef = useRef<BottomSheet>(null);

  const handleAction = (route: string) => {
    bottomSheetRef.current?.close();
    setTimeout(() => {
      router.push(`${route}?itemId=${itemId}`);
    }, 300);
  };

  const handleDismiss = () => {
    router.back();
  };

  return (
    <View style={styles.container}>
      <Pressable style={styles.backdrop} onPress={handleDismiss} />
      
      <BottomSheet
        ref={bottomSheetRef}
        snapPoints={['40%']}
        onClose={handleDismiss}
        enablePanDownToClose
      >
        <View style={styles.content}>
          <Text style={styles.title}>Actions</Text>
          
          {actions.map((action) => (
            <Pressable
              key={action.id}
              style={styles.actionItem}
              onPress={() => handleAction(action.route)}
            >
              <Ionicons name={action.icon as any} size={24} color="#333" />
              <Text style={styles.actionLabel}>{action.label}</Text>
            </Pressable>
          ))}
        </View>
      </BottomSheet>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  backdrop: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: 'rgba(0,0,0,0.3)',
  },
  content: {
    padding: 16,
  },
  title: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 16,
  },
  actionItem: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingVertical: 16,
    borderBottomWidth: 1,
    borderBottomColor: '#f0f0f0',
  },
  actionLabel: {
    fontSize: 16,
    marginLeft: 16,
  },
});

Bottom Tab with Sheet Trigger

// Create a center "+" button that opens a sheet instead of navigating
// app/(tabs)/_layout.tsx
import { Tabs, useRouter } from 'expo-router';
import { Pressable, View, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';

export default function TabLayout() {
  const router = useRouter();

  return (
    <Tabs>
      <Tabs.Screen name="home" />
      <Tabs.Screen name="search" />
      
      {/* Center button - opens sheet instead of screen */}
      <Tabs.Screen
        name="create"
        options={{
          tabBarButton: () => (
            <Pressable
              style={styles.createButton}
              onPress={() => router.push('/sheets/create')}
            >
              <View style={styles.createButtonInner}>
                <Ionicons name="add" size={28} color="#fff" />
              </View>
            </Pressable>
          ),
        }}
      />
      
      <Tabs.Screen name="notifications" />
      <Tabs.Screen name="profile" />
    </Tabs>
  );
}

const styles = StyleSheet.create({
  createButton: {
    top: -20,
    justifyContent: 'center',
    alignItems: 'center',
  },
  createButtonInner: {
    width: 56,
    height: 56,
    borderRadius: 28,
    backgroundColor: '#6366f1',
    justifyContent: 'center',
    alignItems: 'center',
    shadowColor: '#6366f1',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.3,
    shadowRadius: 8,
    elevation: 8,
  },
});

Performance Optimization

Navigation performance is critical for user experience. Slow transitions and janky animations make your app feel sluggish. Let's optimize.

Screen Preloading

// Preload screens that users are likely to visit
import { useEffect } from 'react';
import { useRouter } from 'expo-router';

function ProductCard({ product }: { product: Product }) {
  const router = useRouter();

  // Preload detail screen when card becomes visible
  useEffect(() => {
    // Prefetch the route (if supported)
    router.prefetch(`/product/${product.id}`);
  }, [product.id]);

  return (
    <Pressable onPress={() => router.push(`/product/${product.id}`)}>
      {/* card content */}
    </Pressable>
  );
}

Lazy Loading Screens

// Use React.lazy for heavy screens
import { Suspense, lazy } from 'react';
import { View, ActivityIndicator } from 'react-native';

// Lazy load heavy components
const HeavyChart = lazy(() => import('@/components/HeavyChart'));
const MapView = lazy(() => import('@/components/MapView'));

function AnalyticsScreen() {
  return (
    <View>
      <Suspense fallback={<ActivityIndicator size="large" />}>
        <HeavyChart />
      </Suspense>
    </View>
  );
}

// For screens with maps - only load when needed
function LocationScreen() {
  const [showMap, setShowMap] = useState(false);

  return (
    <View>
      {showMap ? (
        <Suspense fallback={<MapPlaceholder />}>
          <MapView />
        </Suspense>
      ) : (
        <Button title="Load Map" onPress={() => setShowMap(true)} />
      )}
    </View>
  );
}

Optimizing List Navigation

// Optimize FlatList for navigation performance
import { useCallback, memo } from 'react';
import { FlatList } from 'react-native';
import { useRouter } from 'expo-router';

// Memoize list items to prevent re-renders
const ProductItem = memo(function ProductItem({ 
  product, 
  onPress 
}: { 
  product: Product; 
  onPress: (id: string) => void;
}) {
  return (
    <Pressable onPress={() => onPress(product.id)}>
      <Text>{product.title}</Text>
    </Pressable>
  );
});

function ProductList({ products }: { products: Product[] }) {
  const router = useRouter();

  // Memoize navigation handler
  const handlePress = useCallback((id: string) => {
    router.push(`/product/${id}`);
  }, [router]);

  // Memoize renderItem
  const renderItem = useCallback(({ item }: { item: Product }) => (
    <ProductItem product={item} onPress={handlePress} />
  ), [handlePress]);

  // Memoize keyExtractor
  const keyExtractor = useCallback((item: Product) => item.id, []);

  return (
    <FlatList
      data={products}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      // Performance optimizations
      removeClippedSubviews
      maxToRenderPerBatch={10}
      windowSize={5}
      initialNumToRender={10}
      getItemLayout={(data, index) => ({
        length: ITEM_HEIGHT,
        offset: ITEM_HEIGHT * index,
        index,
      })}
    />
  );
}

Preventing Unnecessary Re-renders

// Use useFocusEffect wisely - don't over-fetch
import { useFocusEffect } from '@react-navigation/native';
import { useCallback, useRef } from 'react';

function DataScreen() {
  const hasLoaded = useRef(false);
  const lastFetch = useRef<number>(0);

  useFocusEffect(
    useCallback(() => {
      const now = Date.now();
      const timeSinceLastFetch = now - lastFetch.current;
      
      // Only refetch if:
      // 1. Never loaded before, OR
      // 2. More than 5 minutes since last fetch
      if (!hasLoaded.current || timeSinceLastFetch > 5 * 60 * 1000) {
        fetchData();
        lastFetch.current = now;
        hasLoaded.current = true;
      }
    }, [])
  );

  return (/* ... */);
}

// Use React Query for intelligent caching
import { useQuery } from '@tanstack/react-query';

function OptimizedDataScreen() {
  const { data, isLoading } = useQuery({
    queryKey: ['data'],
    queryFn: fetchData,
    staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
    cacheTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
  });

  return (/* ... */);
}

Reducing Navigation Stack Memory

// Replace instead of push for certain flows
import { useRouter } from 'expo-router';

function CheckoutFlow() {
  const router = useRouter();

  const handleComplete = () => {
    // Replace entire checkout flow with success screen
    // This removes checkout screens from memory
    router.replace('/order-success');
  };

  // For wizard steps, consider replace instead of push
  const goToNextStep = () => {
    router.replace('/checkout/step-2'); // Not push
  };
}

// Dismiss multiple screens at once
function DeepScreen() {
  const router = useRouter();

  const goBackToRoot = () => {
    // Dismiss 3 screens at once
    router.dismiss(3);
    
    // Or use dismissAll
    router.dismissAll();
  };
}
Navigation Performance Checklist βœ“ Do β€’ Memoize components β€’ Use getItemLayout β€’ Lazy load heavy screens β€’ Cache API responses β€’ Use replace for flows βœ— Don't β€’ Fetch on every focus β€’ Inline functions in render β€’ Deep stack without cleanup β€’ Heavy animations β€’ Sync operations on navigate πŸ“Š Measure β€’ React DevTools Profiler β€’ Flipper performance β€’ Console timestamps β€’ useRenderCount hook β€’ Real device testing

Navigation Analytics

Tracking navigation helps you understand user behavior, identify pain points, and measure feature adoption.

Basic Screen Tracking

// hooks/useNavigationTracking.ts
import { useEffect } from 'react';
import { usePathname, useSegments } from 'expo-router';

export function useNavigationTracking() {
  const pathname = usePathname();
  const segments = useSegments();

  useEffect(() => {
    // Track screen view
    trackScreenView({
      screen_name: pathname,
      screen_class: segments.join('/'),
      timestamp: Date.now(),
    });
  }, [pathname, segments]);
}

// analytics.ts - example implementations
export function trackScreenView(params: {
  screen_name: string;
  screen_class: string;
  timestamp: number;
}) {
  // Firebase Analytics
  // analytics().logScreenView(params);
  
  // Mixpanel
  // mixpanel.track('Screen View', params);
  
  // Amplitude
  // amplitude.logEvent('Screen View', params);
  
  // Custom backend
  fetch('/api/analytics', {
    method: 'POST',
    body: JSON.stringify({
      event: 'screen_view',
      ...params,
    }),
  });
}

// Use in root layout
export default function RootLayout() {
  useNavigationTracking();
  
  return <Stack>{/* ... */}</Stack>;
}

Comprehensive Navigation Logger

// hooks/useNavigationLogger.ts
import { useEffect, useRef } from 'react';
import { usePathname, useSegments, useLocalSearchParams } from 'expo-router';

interface NavigationEvent {
  type: 'screen_view' | 'navigation' | 'deep_link';
  from?: string;
  to: string;
  params?: Record<string, string>;
  timestamp: number;
  duration?: number;
}

const navigationHistory: NavigationEvent[] = [];

export function useNavigationLogger() {
  const pathname = usePathname();
  const segments = useSegments();
  const params = useLocalSearchParams();
  const previousPath = useRef<string | null>(null);
  const screenStartTime = useRef<number>(Date.now());

  useEffect(() => {
    const now = Date.now();
    
    // Calculate time spent on previous screen
    const duration = previousPath.current 
      ? now - screenStartTime.current 
      : undefined;

    // Log the navigation event
    const event: NavigationEvent = {
      type: 'screen_view',
      from: previousPath.current || undefined,
      to: pathname,
      params: Object.keys(params).length > 0 
        ? params as Record<string, string> 
        : undefined,
      timestamp: now,
      duration,
    };

    navigationHistory.push(event);
    
    // Send to analytics
    sendToAnalytics(event);

    // Update refs
    previousPath.current = pathname;
    screenStartTime.current = now;

    // Cleanup: track time when leaving screen
    return () => {
      const exitDuration = Date.now() - screenStartTime.current;
      sendToAnalytics({
        type: 'navigation',
        from: pathname,
        to: 'unknown', // Will be updated by next screen
        timestamp: Date.now(),
        duration: exitDuration,
      });
    };
  }, [pathname]);

  return { navigationHistory };
}

function sendToAnalytics(event: NavigationEvent) {
  // Console logging for development
  if (__DEV__) {
    console.log('[Navigation]', {
      ...event,
      duration: event.duration ? `${event.duration}ms` : undefined,
    });
  }

  // Send to your analytics service
  // analytics.track('navigation', event);
}

User Flow Analysis

// Track complete user flows
interface UserFlow {
  flowName: string;
  steps: string[];
  startTime: number;
  endTime?: number;
  completed: boolean;
}

const activeFlows = new Map<string, UserFlow>();

export function startFlow(flowName: string) {
  activeFlows.set(flowName, {
    flowName,
    steps: [],
    startTime: Date.now(),
    completed: false,
  });
}

export function addFlowStep(flowName: string, step: string) {
  const flow = activeFlows.get(flowName);
  if (flow) {
    flow.steps.push(step);
  }
}

export function completeFlow(flowName: string, success: boolean) {
  const flow = activeFlows.get(flowName);
  if (flow) {
    flow.endTime = Date.now();
    flow.completed = success;
    
    // Send flow analytics
    sendFlowAnalytics(flow);
    
    // Cleanup
    activeFlows.delete(flowName);
  }
}

// Usage in checkout flow
function CheckoutScreen() {
  useEffect(() => {
    startFlow('checkout');
    addFlowStep('checkout', 'cart_review');
  }, []);
  
  const handlePaymentComplete = () => {
    addFlowStep('checkout', 'payment_success');
    completeFlow('checkout', true);
    router.push('/order-confirmation');
  };
}

Error Tracking in Navigation

// Track navigation errors
import { ErrorBoundary } from 'react-error-boundary';

function NavigationErrorFallback({ error, resetErrorBoundary }) {
  useEffect(() => {
    // Track the navigation error
    trackError({
      type: 'navigation_error',
      error: error.message,
      stack: error.stack,
      timestamp: Date.now(),
    });
  }, [error]);

  return (
    <View style={styles.errorContainer}>
      <Text>Something went wrong</Text>
      <Button title="Go Home" onPress={() => {
        resetErrorBoundary();
        router.replace('/');
      }} />
    </View>
  );
}

// Wrap your layout
export default function RootLayout() {
  return (
    <ErrorBoundary FallbackComponent={NavigationErrorFallback}>
      <Stack>{/* ... */}</Stack>
    </ErrorBoundary>
  );
}
flowchart LR
    A[User Action] --> B[Navigation Event]
    B --> C{Analytics Pipeline}
    
    C --> D[Screen Views]
    C --> E[User Flows]
    C --> F[Error Tracking]
    C --> G[Performance Metrics]
    
    D --> H[Dashboard]
    E --> H
    F --> H
    G --> H
    
    style H fill:#e8f5e9
                

Hands-On Exercises

Exercise 1: Custom Fade Transition

Create a custom screen transition that:

  • Fades out the current screen while scaling down
  • Fades in the new screen while scaling up
  • Has a duration of 400ms
  • Works with gesture navigation
βœ… Solution
import { StackCardStyleInterpolator } from '@react-navigation/stack';

const fadeScaleTransition: StackCardStyleInterpolator = ({
  current,
  next,
}) => {
  return {
    cardStyle: {
      opacity: current.progress.interpolate({
        inputRange: [0, 1],
        outputRange: [0, 1],
      }),
      transform: [
        {
          scale: current.progress.interpolate({
            inputRange: [0, 1],
            outputRange: [0.85, 1],
          }),
        },
      ],
    },
    overlayStyle: {
      opacity: current.progress.interpolate({
        inputRange: [0, 1],
        outputRange: [0, 0.3],
      }),
    },
  };
};

// Usage
<Stack
  screenOptions={{
    cardStyleInterpolator: fadeScaleTransition,
    transitionSpec: {
      open: {
        animation: 'timing',
        config: { duration: 400 },
      },
      close: {
        animation: 'timing',
        config: { duration: 400 },
      },
    },
    gestureEnabled: true,
    gestureDirection: 'horizontal',
  }}
>

Exercise 2: Shared Element Gallery

Build an image gallery with shared element transitions:

  • Grid of thumbnails on the list screen
  • Full-screen image on the detail screen
  • Smooth transition between thumbnail and full image
πŸ’‘ Hint

Use Reanimated's sharedTransitionTag on both Animated.Image components with matching unique IDs. The transition happens automatically when navigating between screens.

Exercise 3: Action Sheet Navigator

Create a reusable action sheet that:

  • Opens as a bottom sheet
  • Accepts dynamic action items
  • Navigates to different screens based on selection
  • Closes smoothly before navigating
βœ… Solution Outline
// app/sheets/[actions].tsx
import { useLocalSearchParams, useRouter } from 'expo-router';
import BottomSheet from '@gorhom/bottom-sheet';

export default function ActionSheet() {
  const { actions } = useLocalSearchParams<{ actions: string }>();
  const router = useRouter();
  const parsedActions = JSON.parse(actions || '[]');
  const sheetRef = useRef<BottomSheet>(null);

  const handleSelect = async (route: string) => {
    sheetRef.current?.close();
    await new Promise(r => setTimeout(r, 300));
    router.push(route);
  };

  return (
    <BottomSheet ref={sheetRef} snapPoints={['40%']} enablePanDownToClose>
      {parsedActions.map((action) => (
        <Pressable key={action.id} onPress={() => handleSelect(action.route)}>
          <Text>{action.label}</Text>
        </Pressable>
      ))}
    </BottomSheet>
  );
}

// Usage
router.push({
  pathname: '/sheets/actions',
  params: {
    actions: JSON.stringify([
      { id: 'edit', label: 'Edit', route: '/edit' },
      { id: 'delete', label: 'Delete', route: '/delete' },
    ]),
  },
});

Exercise 4: Navigation Analytics Dashboard

Implement comprehensive navigation tracking:

  • Track all screen views with timestamps
  • Calculate time spent on each screen
  • Track navigation paths (from β†’ to)
  • Create a debug screen showing navigation history
βœ… Solution
// Store navigation history
const navigationStore = {
  history: [] as NavigationEvent[],
  
  addEvent(event: NavigationEvent) {
    this.history.push(event);
    // Keep last 100 events
    if (this.history.length > 100) {
      this.history.shift();
    }
  },
  
  getAverageTimeOnScreen(screenName: string) {
    const screenEvents = this.history.filter(
      e => e.to === screenName && e.duration
    );
    if (screenEvents.length === 0) return 0;
    const total = screenEvents.reduce((sum, e) => sum + (e.duration || 0), 0);
    return total / screenEvents.length;
  },
  
  getMostVisitedScreens() {
    const counts = this.history.reduce((acc, e) => {
      acc[e.to] = (acc[e.to] || 0) + 1;
      return acc;
    }, {} as Record<string, number>);
    
    return Object.entries(counts)
      .sort(([, a], [, b]) => b - a)
      .slice(0, 10);
  },
};

// Debug screen
function NavigationDebugScreen() {
  return (
    <ScrollView>
      <Text>Most Visited:</Text>
      {navigationStore.getMostVisitedScreens().map(([screen, count]) => (
        <Text key={screen}>{screen}: {count} views</Text>
      ))}
      
      <Text>Recent History:</Text>
      {navigationStore.history.slice(-20).reverse().map((e, i) => (
        <Text key={i}>{e.from} β†’ {e.to} ({e.duration}ms)</Text>
      ))}
    </ScrollView>
  );
}

Summary

Congratulations! You've completed the Navigation module and mastered advanced patterns for creating polished, performant navigation experiences.

🎯 Key Takeaways

  • Custom Transitions: Use cardStyleInterpolator for unique animations
  • Shared Elements: Match sharedTransitionTag between screens for seamless transitions
  • Modal Patterns: Stack modals, use transparent modals for dialogs
  • Bottom Sheets: @gorhom/bottom-sheet for native-feeling interactions
  • Performance: Memoize, lazy load, use replace over push for flows
  • Analytics: Track screen views, user flows, and navigation errors

πŸ† Module 6 Complete!

You've mastered navigation with Expo Router:

  • βœ… Navigation concepts and patterns
  • βœ… File-based routing with Expo Router
  • βœ… Stack, Tab, and Drawer navigation
  • βœ… Nested navigation architectures
  • βœ… Authentication flows
  • βœ… Deep linking and push notifications
  • βœ… Advanced patterns and optimization

What's Next?

In the next module, we'll explore State Managementβ€”from React's built-in state to Context API, Zustand, and advanced state patterns for complex applications.