Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Learning Objectives

By the end of this module, you’ll understand:
  • Nested navigator patterns
  • Authentication flows
  • Deep linking and universal links
  • Navigation state persistence
  • Custom navigators
  • Modals and overlay patterns
  • TypeScript with navigation

Why Navigation Gets Complex

If basic navigation is like having a hallway with rooms, advanced navigation is like designing an entire building — you need elevators (stacks), floors (tabs), and service corridors (drawers), all connected so people can move between them naturally without getting lost. The most common architecture in production apps is a root stack that switches between an auth flow and a main app flow, where the main app flow contains tabs, and each tab contains its own stack of screens. Understanding how these navigators nest is the key to building apps that feel polished.

Nested Navigators

Combine multiple navigator types to create rich navigation hierarchies. The critical insight: each navigator manages its own history stack independently, so a stack inside a tab only tracks screens within that tab.

Tab + Stack Pattern

import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';

const Tab = createBottomTabNavigator();
const HomeStack = createNativeStackNavigator();
const SettingsStack = createNativeStackNavigator();

// Each tab gets its own stack navigator, so pushing a screen
// within the Home tab does not affect the Settings tab's history.
// This matches the behavior users expect from apps like Instagram or Twitter.
function HomeStackScreen() {
  return (
    <HomeStack.Navigator>
      <HomeStack.Screen name="Home" component={HomeScreen} />
      <HomeStack.Screen name="Details" component={DetailsScreen} />
      <HomeStack.Screen name="Profile" component={ProfileScreen} />
    </HomeStack.Navigator>
  );
}

function SettingsStackScreen() {
  return (
    <SettingsStack.Navigator>
      <SettingsStack.Screen name="Settings" component={SettingsScreen} />
      <SettingsStack.Screen name="Account" component={AccountScreen} />
      <SettingsStack.Screen name="Privacy" component={PrivacyScreen} />
    </SettingsStack.Navigator>
  );
}

// The Tab.Navigator wraps the stacks. Setting headerShown: false on the
// Tab.Navigator avoids double headers (one from the tab, one from the stack).
export default function App() {
  return (
    <NavigationContainer>
      <Tab.Navigator screenOptions={{ headerShown: false }}>
        <Tab.Screen name="Home" component={HomeStackScreen} />
        <Tab.Screen name="Settings" component={SettingsStackScreen} />
      </Tab.Navigator>
    </NavigationContainer>
  );
}
Mobile pitfall: When nesting a stack inside a tab, the stack’s screens stay mounted when you switch tabs (the tab navigator preserves its children by default). This is a feature, not a bug — users expect to return to a tab and find it exactly where they left it. But it means those screens keep consuming memory. For memory-sensitive apps, consider using unmountOnBlur: true in the tab screen options, or resetting the stack when the tab receives focus.

Authentication Flow

The auth flow pattern is arguably the most important navigation pattern in mobile apps. The idea is simple but powerful: conditionally render entirely different navigator trees based on auth state, rather than using guards on individual screens. This approach is more secure (the main app screens literally do not exist in memory when the user is not authenticated) and produces smooth transitions because React Navigation animates the switch automatically.

Auth Navigator Pattern

const Stack = createNativeStackNavigator();

function RootNavigator() {
  const { user, isLoading } = useAuth();

  // Show a splash screen while checking stored tokens on app launch.
  // Without this, the user would briefly see the login screen before
  // being redirected, which feels broken.
  if (isLoading) {
    return <SplashScreen />;
  }

  // React Navigation detects that the screen set changed and animates
  // the transition automatically. No manual navigation.navigate() needed.
  return (
    <Stack.Navigator screenOptions={{ headerShown: false }}>
      {user ? (
        <Stack.Screen name="Main" component={MainNavigator} />
      ) : (
        <Stack.Screen name="Auth" component={AuthNavigator} />
      )}
    </Stack.Navigator>
  );
}

// Auth flow
function AuthNavigator() {
  return (
    <Stack.Navigator>
      <Stack.Screen name="Login" component={LoginScreen} />
      <Stack.Screen name="Register" component={RegisterScreen} />
      <Stack.Screen name="ForgotPassword" component={ForgotPasswordScreen} />
    </Stack.Navigator>
  );
}

// Main app flow
function MainNavigator() {
  return (
    <Tab.Navigator>
      <Tab.Screen name="Home" component={HomeScreen} />
      <Tab.Screen name="Profile" component={ProfileScreen} />
    </Tab.Navigator>
  );
}

Deep Linking

Deep linking lets external sources (push notifications, emails, other apps, web URLs) open your app directly to a specific screen. Think of it like bookmarking a specific page in a book rather than always starting at the cover. There are two types: URL scheme links (myapp://product/123) that only work when the app is installed, and universal links (https://myapp.com/product/123) that open the app if installed or fall back to the website.

Configuration

// Linking configuration maps URL patterns to screen names.
// The nesting matches your navigator hierarchy.
const linking = {
  prefixes: ['myapp://', 'https://myapp.com'],
  config: {
    screens: {
      Main: {
        screens: {
          Home: 'home',
          Profile: 'profile/:userId',
          Settings: 'settings',
        },
      },
      Product: {
        path: 'product/:id',
        parse: {
          id: Number,
        },
      },
      NotFound: '*',
    },
  },
};

// App entry
export default function App() {
  return (
    <NavigationContainer linking={linking} fallback={<SplashScreen />}>
      <RootNavigator />
    </NavigationContainer>
  );
}

State persistence saves the user’s navigation position to storage so the app can restore it on next launch. This is especially valuable during development (you can restart the app and land right back where you were) and for users who frequently background and resume your app.

Persist Navigation State

import AsyncStorage from '@react-native-async-storage/async-storage';

// Version the key so you can invalidate stale state after navigation changes.
// If you add or rename screens between releases, old persisted state will crash.
const PERSISTENCE_KEY = 'NAVIGATION_STATE_V1';

export default function App() {
  const [isReady, setIsReady] = useState(false);
  const [initialState, setInitialState] = useState();

  useEffect(() => {
    const restoreState = async () => {
      try {
        const savedState = await AsyncStorage.getItem(PERSISTENCE_KEY);
        if (savedState) {
          setInitialState(JSON.parse(savedState));
        }
      } finally {
        setIsReady(true);
      }
    };

    if (!isReady) {
      restoreState();
    }
  }, [isReady]);

  if (!isReady) {
    return <SplashScreen />;
  }

  return (
    <NavigationContainer
      initialState={initialState}
      onStateChange={(state) =>
        AsyncStorage.setItem(PERSISTENCE_KEY, JSON.stringify(state))
      }
    >
      <RootNavigator />
    </NavigationContainer>
  );
}

Modals and Overlays

Modals in React Navigation are just screens with a different presentation style. Using Stack.Group to separate regular screens from modal screens keeps your navigator clean and gives you platform-appropriate modal animations (slide-up on iOS, fade on Android) for free.
<Stack.Navigator>
  {/* Regular screens push from the right (iOS) or fade (Android) */}
  <Stack.Group>
    <Stack.Screen name="Home" component={HomeScreen} />
    <Stack.Screen name="Profile" component={ProfileScreen} />
  </Stack.Group>
  
  {/* Modal screens slide up from the bottom, appearing as a card
      on top of the previous screen (visible behind on iOS) */}
  <Stack.Group screenOptions={{ presentation: 'modal' }}>
    <Stack.Screen name="CreatePost" component={CreatePostScreen} />
    <Stack.Screen name="Settings" component={SettingsScreen} />
  </Stack.Group>
  
  {/* fullScreenModal covers the entire screen, useful for media viewers
      or flows where you do not want the user to see the screen behind */}
  <Stack.Group screenOptions={{ presentation: 'fullScreenModal' }}>
    <Stack.Screen name="ImageViewer" component={ImageViewerScreen} />
  </Stack.Group>
</Stack.Navigator>
iOS design guideline: Use card-style modals (presentation: 'modal') for self-contained tasks like composing a message or filling out a form. Use full-screen modals for immersive experiences like photo/video viewers. This matches the behavior users expect from native iOS apps.

Best Practices

  1. Keep navigators simple — Aim for a maximum of 3 levels of nesting (root stack, tab, inner stack). Deeper nesting creates confusing navigation behavior and makes TypeScript types painful to maintain.
  2. Use TypeScript for all navigation params — Catch “screen not found” and “missing required param” errors at compile time instead of runtime crashes in production.
  3. Handle auth state at the root — Show a splash screen while checking stored tokens. Never flash the login screen before redirecting to the main app.
  4. Persist navigation state in dev, disable in production — It speeds up development but can cause crashes in production if the navigation structure changes between releases.
  5. Test deep links on real devices — Simulators and emulators handle URL schemes differently from physical devices. Test both cold-start (app not running) and warm-start (app backgrounded) scenarios.
  6. Reset tab stacks on tab press — Users expect tapping the active tab icon to scroll to top or pop to the root screen. Use the tabPress event listener to implement this.