React Navigation: Composing Stacks, Tabs and Drawers Imperatively
AI generated
RN
native
React Native · Navigation · TypeScript · Mobile Commerce
React Navigation: Stacks, Tabs and Drawers
composed imperatively instead of nested at random

React Navigation gives development teams full control over the navigation tree of a React Native app: createNativeStackNavigator, createBottomTabNavigator and createDrawerNavigator can be nested deliberately, typed with TypeScript, and wired to deep links through a linking configuration, all without relying on any file based routing convention.

18 min read Stack · Tab · Drawer · TypeScript · Linking React Navigation 6/7 · React Native

1. Why Imperative Navigator Composition With React Navigation Matters

React Navigation is the most widely used navigation solution for React Native, and it works on an imperative composition model: you create navigators explicitly through factory functions such as createNativeStackNavigator, createBottomTabNavigator and createDrawerNavigator, then nest them deliberately in JSX. That stands in contrast to file based routing conventions, where the folder structure itself defines the navigation. The advantage of the imperative approach is that the navigation tree is never implicit, every nesting level sits visibly in the code and can be shaped exactly to match the app's information architecture.

This article deliberately does not cover Expo Router's file based app/ directory convention, that is a separate topic. Instead it focuses on React Navigation as a library used directly: for teams running a bare React Native app without Expo, or for projects with highly custom navigation state machines where full control over navigator creation and nesting is essential. It is worth knowing that Expo Router itself is built on top of React Navigation internally, so understanding how Stack, Tab and Drawer nest also means understanding what happens under the hood of Expo Router.

The following sections show how to nest stacks inside tabs and tabs inside a drawer with React Navigation, how to make navigation type safe with TypeScript, what actually differs between navigate, push and replace, and how a linking configuration maps deep links precisely onto the nested navigator structure.

2. Stack Navigator: createNativeStackNavigator as the Foundation

The Stack Navigator is the basic building block for linear navigation flows. createNativeStackNavigator from the @react-navigation/native-stack package uses UINavigationController on iOS and Fragment transitions on Android, so transitions run natively instead of relying on JavaScript driven animations. A React Navigation stack consists of a Stack.Navigator component with several Stack.Screen children, each with a unique name and its associated component. New screens are placed on top of the stack, and the back gesture or back button removes the topmost entry again.

Typical stacks in a commerce app are product list to product detail to cart to checkout, a clearly linear path where the user always knows how to get back. initialRouteName sets a stack's starting screen regardless of the order in which Stack.Screen elements are declared. A single global stack for the whole app quickly runs into trouble, though, once parallel areas such as home, cart and account need to be visible at the same time, which is exactly why the stack gets combined with a Tab Navigator, as described in the next section.


# Install React Navigation core and navigator packages
npm install @react-navigation/native @react-navigation/native-stack
npm install @react-navigation/bottom-tabs @react-navigation/drawer

# Required native dependencies (bare React Native, not needed with Expo managed workflow)
npm install react-native-screens react-native-safe-area-context
npm install react-native-gesture-handler react-native-reanimated

# iOS only: link native modules via CocoaPods
cd ios && pod install && cd ..

3. Tab Navigator: createBottomTabNavigator for Primary Navigation

createBottomTabNavigator from @react-navigation/bottom-tabs represents the primary navigation layer, usually rendered as a bottom tab bar with three to five entries such as home, search, cart and account. Each Tab.Screen receives a tabBarIcon function that renders a different icon depending on focus state, and tabBarBadge can display, for example, the number of items in the cart directly on the tab icon. This primary layer in React Navigation differs fundamentally from a stack: tabs sit side by side rather than stacked on top of each other, and switching tabs is not a forward or backward step in history.

An important aspect of using tabs is their memory behavior: by default every tab screen that has been visited once stays mounted, so switching between tabs happens instantly without re-rendering. For more complex tabs with their own local state, this can be adjusted per screen through the navigator option unmountOnBlur, which saves memory but resets the state whenever the tab regains focus. For most commerce apps the benefit of keeping screens mounted wins out, because scroll position and form input survive when the user briefly switches to another tab.

4. Drawer Navigator and Nesting Stacks Inside Tabs Inside a Drawer

createDrawerNavigator from @react-navigation/drawer is well suited to secondary, rarely used destinations such as store selection, support, legal pages or account switching, reachable via a side menu instead of a permanent tab bar. The real strength of React Navigation only shows once Drawer, Tab and Stack are correctly nested inside one another: the drawer forms the outermost layer, inside it sits the tab navigator, and each individual tab in turn gets its own stack navigator. That way the tab bar stays visible while navigation inside a tab can go arbitrarily deep, without a push in one tab affecting navigation in another tab.

The nesting order is not a matter of taste, it has concrete consequences: if tabs were placed on the outside instead and the drawer only wrapped a single tab's content, the tab bar would no longer disappear correctly when the drawer opens, and the header would show up at the wrong nesting level. Another practical point is that when both the drawer and a nested stack claim the edge of the screen for swipe gestures, a gesture conflict results. In practice teams often disable the stack's internal back swipe gesture on the topmost screen of each tab, or restrict the drawer opening gesture to a narrow edge region.


// App.js - compose Drawer > Tabs > Stacks
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createDrawerNavigator } from '@react-navigation/drawer';

const HomeStack = createNativeStackNavigator();
const CartStack = createNativeStackNavigator();
const Tab = createBottomTabNavigator();
const Drawer = createDrawerNavigator();

// Each tab gets its own stack, so pushing a detail screen
// does not hide the tab bar and back navigation stays local to that tab
function HomeStackScreen() {
  return (
    <HomeStack.Navigator screenOptions={{ headerShown: true }}>
      <HomeStack.Screen name="ProductList" component={ProductListScreen} />
      <HomeStack.Screen name="ProductDetail" component={ProductDetailScreen} />
    </HomeStack.Navigator>
  );
}

function CartStackScreen() {
  return (
    <CartStack.Navigator>
      <CartStack.Screen name="Cart" component={CartScreen} />
      <CartStack.Screen name="Checkout" component={CheckoutScreen} />
    </CartStack.Navigator>
  );
}

function MainTabs() {
  return (
    <Tab.Navigator screenOptions={{ tabBarActiveTintColor: '#4338ca' }}>
      <Tab.Screen name="HomeTab" component={HomeStackScreen} options={{ title: 'Home' }} />
      <Tab.Screen name="CartTab" component={CartStackScreen} options={{ title: 'Cart', tabBarBadge: 3 }} />
      <Tab.Screen name="Account" component={AccountScreen} />
    </Tab.Navigator>
  );
}

export default function App() {
  return (
    <NavigationContainer>
      <Drawer.Navigator>
        <Drawer.Screen name="Main" component={MainTabs} options={{ headerShown: false }} />
        <Drawer.Screen name="Support" component={SupportScreen} />
      </Drawer.Navigator>
    </NavigationContainer>
  );
}

5. Typed Navigation: RootStackParamList With TypeScript

Without typing, navigation.navigate('ProductDetail', { id: 42 }) accepts any route name and any params object, so typos in the route name or missing parameters only surface at runtime. React Navigation solves this with a central RootStackParamList type that maps every route name to its expected params type, undefined for screens without params. This type is used both as the generic parameter of NativeStackNavigationProp for the useNavigation hook, and with RouteProp for useRoute, so that route.params is precisely typed in every screen.

The practical effect is that TypeScript reports an error already at compile time if a screen name is misspelled or a required parameter is missing, instead of the error only becoming visible after a real user tap. Especially on larger teams working on different screens in parallel, this typing prevents the params contract between screens from drifting apart unnoticed. Because RootStackParamList lives in one central file, any change to a parameter immediately becomes visible at every call site that targets the affected screen.


// navigation/types.ts - central param list shared across the app
export type RootStackParamList = {
  ProductList: undefined;
  ProductDetail: { productId: string; sku: string };
  Checkout: { cartId: string };
};

// ProductListScreen.tsx
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';

type ProductListNavProp = NativeStackNavigationProp<RootStackParamList, 'ProductList'>;

function ProductListScreen() {
  const navigation = useNavigation<ProductListNavProp>();

  const openProduct = (productId, sku) => {
    // TypeScript checks the route name and the shape of params at compile time
    navigation.navigate('ProductDetail', { productId, sku });
  };

  return <ProductCard onPress={openProduct} />;
}

// ProductDetailScreen.tsx
import { useRoute } from '@react-navigation/native';
import type { RouteProp } from '@react-navigation/native';
import type { RootStackParamList } from '../navigation/types';

type ProductDetailRouteProp = RouteProp<RootStackParamList, 'ProductDetail'>;

function ProductDetailScreen() {
  const route = useRoute<ProductDetailRouteProp>();
  const { productId, sku } = route.params; // fully typed, no manual casting needed
  return <ProductView productId={productId} sku={sku} />;
}

The three methods navigate, push and replace look similar on the surface, but in React Navigation they differ fundamentally in how they treat navigation history. navigation.navigate('ProductList') jumps to an existing instance of the route already in the current stack if one is present, instead of placing a new one on top, which is ideal for returning to a known screen without needlessly growing the history. navigation.push('ProductDetail', { productId }), on the other hand, always places a new instance on the stack, even if the same route name is already present, which matters for related products where the user should be able to click through several detail screens and go back through each one individually.

navigation.replace('OrderConfirmation', { orderId }) replaces the current screen without creating a new history entry, which prevents, for example, a user from landing back on the payment screen after a successful checkout by pressing the back button. In addition there is goBack() for a single step back and popToTop() to clear the entire stack down to the first screen, useful when a user should land back on the home screen after logging out. The deliberate choice between these methods directly shapes how back navigation feels from the user's perspective.


// Difference between navigate, push and replace
function ProductDetailScreen({ navigation, route }) {
  const openRelatedProduct = (relatedId) => {
    // push always adds a new screen instance, even to the same route name,
    // so users can drill through several related products and go back through each one
    navigation.push('ProductDetail', { productId: relatedId });
  };

  const goToProductList = () => {
    // navigate reuses an existing ProductList instance already in the stack
    // instead of stacking a duplicate on top
    navigation.navigate('ProductList');
  };

  const finishCheckout = () => {
    // replace swaps the current screen so Checkout never appears
    // again when the user presses the hardware back button
    navigation.replace('OrderConfirmation', { orderId: '10293' });
  };

  return (
    <ProductActions
      onRelated={openRelatedProduct}
      onBack={goToProductList}
      onCheckout={finishCheckout}
    />
  );
}

// Header customization via screenOptions
function HomeStackScreen() {
  return (
    <HomeStack.Navigator
      screenOptions={{
        headerStyle: { backgroundColor: '#1e1b4b' },
        headerTintColor: '#ffffff',
        headerBackTitleVisible: false,
      }}
    >
      <HomeStack.Screen
        name="ProductList"
        component={ProductListScreen}
        options={{
          headerTitle: 'Storefront',
          headerRight: () => <CartBadgeButton />,
        }}
      />
    </HomeStack.Navigator>
  );
}

7. screenOptions and Header Customization

The screenOptions prop on the navigator itself defines default settings for every screen it contains, while the options prop of an individual Screen element overrides those defaults selectively. In React Navigation this lets you, for example, set headerStyle and headerTintColor once for an entire stack to match the shop's corporate design, while individual screens define a different title or their own header buttons via headerRight, such as a cart button with a badge counter shown directly in the product list header.

Other commonly used options include headerShown: false to avoid duplicate headers on nested navigators, headerBackTitleVisible: false to hide the text next to the back arrow on iOS, and gestureEnabled to selectively disable the back swipe gesture on a per screen basis, for instance on a payment screen the user should not be able to leave by accident with a swipe. This fine grained control through screenOptions and options makes it possible to keep header behavior consistent across the whole app while still deviating in specific places, without having to reconfigure every screen from scratch.

8. Linking Configuration for Deep Links

For push notifications, marketing emails or universal links to point directly at the right screen inside the nested navigator structure, React Navigation needs a linking configuration object passed to the NavigationContainer component. It defines prefixes for the app's custom URL scheme and for universal HTTPS links, plus a nested config.screens object whose structure must match exactly the nesting of drawer, tabs and stacks in the navigation tree.

A link such as https://mironsoft.de/product/1029 therefore has to resolve through the same drawer to tab to stack to screen path that is followed during manual navigation, otherwise the deep link does open the right screen but without the expected tab bar, or with an incorrectly restored navigation state. For more complex cases where the URL structure and navigator nesting cannot be mapped one to one directly, React Navigation offers getStateFromPath as an extension point that lets you customize the path to state conversion yourself, for example to map legacy URLs from an existing Magento storefront structure onto the app's internal navigation.


{
  "prefixes": ["mironshop://", "https://mironsoft.de"],
  "config": {
    "screens": {
      "Main": {
        "screens": {
          "HomeTab": {
            "screens": {
              "ProductList": "products",
              "ProductDetail": "product/:productId"
            }
          },
          "CartTab": {
            "screens": {
              "Cart": "cart",
              "Checkout": "checkout"
            }
          }
        }
      },
      "Support": "support"
    }
  }
}

9. React Navigation Compared: Stack, Tab, Drawer and Expo Router

Each of the three navigator types in React Navigation solves a different problem, and choosing the wrong combination leads to noticeably worse user experience. The table below lays out the key differences before we cover when a team should reach for React Navigation directly instead of the file based Expo Router convention.

Criterion Stack Navigator Tab Navigator Drawer Navigator
Use case Linear drill-down flows (list to detail to checkout) Parallel top level sections (home, cart, account) Rare, secondary destinations (support, store switch)
Memory behavior Screens mount on push, unmount on pop All visited tabs stay mounted by default Usually lazy, mounted only when opened
Typical screen count 3 to 15 screens per stack 3 to 5 tabs 5 to 10 drawer entries
Gesture support Edge swipe back gesture (iOS) Optional swipe between tabs Edge swipe opens the side menu

A team should reach for React Navigation directly instead of Expo Router when the app runs on plain React Native without Expo modules, for instance in an existing bare workflow project with deep native integrations that cannot easily be moved into the Expo managed workflow. Direct use is equally sensible when the app needs a highly custom navigation state machine, for example navigator trees generated dynamically from backend configuration, or custom navigators built with createNavigatorFactory that do not fit into a folder convention. For new, simply structured apps without such requirements Expo Router is often the faster starting point, but understanding the underlying React Navigation mechanics stays essential either way.

Mironsoft

React Native apps and mobile commerce storefronts built on Magento

A navigation structure that grows with your shop?

We build React Native apps connected to Magento via REST or GraphQL APIs, including a cleanly nested React Navigation structure of stack, tab and drawer, typed navigation with TypeScript, and deep link configuration for push notifications and marketing campaigns.

Navigation Architecture

Plan the nesting of stack, tab and drawer around your shop's information architecture

Code Review

Audit an existing React Navigation implementation for typing, gestures and deep links

Magento Integration

Integrate product catalog, checkout and customer account into the app's navigation via REST/GraphQL

10. Summary

The core insight around React Navigation is that the navigation tree of a React Native app does not emerge by accident, it results from deliberate, imperative composition of stack, tab and drawer navigators. A drawer as the outermost layer, a tab navigator inside it, and a dedicated stack per tab inside that is the most robust pattern for commerce apps with parallel top level navigation and deep drill-down flows within individual areas. Typed navigation through RootStackParamList keeps typos in route names or missing parameters from surfacing only during testing on a real device.

The difference between navigate, push and replace directly shapes back navigation from the user's perspective, and a linking configuration that follows the navigator nesting exactly makes deep links from push notifications and marketing emails reliably usable. Teams running a bare React Native app without Expo, or needing a highly custom navigation state machine, benefit from the direct, explicit use of React Navigation over file based conventions.

React Navigation: Stacks, Tabs and Drawers, the Key Takeaways

Nesting

Drawer on the outside, tab navigator inside it, a dedicated stack per tab. This order keeps headers and gestures at the correct level.

Typing

Wire up RootStackParamList with NativeStackNavigationProp and RouteProp, typos and missing params surface at compile time.

navigate vs. push vs. replace

navigate jumps to an existing instance, push always adds a new one, replace swaps without a history entry. Choose deliberately.

Deep Links

The linking configuration must mirror the navigator nesting exactly, otherwise the tab bar or navigation state is missing on open.

11. FAQ: React Navigation With Stacks, Tabs and Drawers

1What is the difference between React Navigation and Expo Router?
React Navigation is the underlying library with an imperative API. Expo Router builds on top of it and derives the structure from the app directory's folder structure.
2When React Navigation instead of Expo Router?
In bare React Native projects without Expo, with deep native integrations, and with highly custom navigation state machines using custom navigators.
3navigate vs. push vs. replace?
navigate jumps to an existing instance. push always adds a new one. replace swaps without a new history entry, useful after checkout or login.
4Why a dedicated stack per tab?
Without one, a push would cover the tab bar. With a dedicated stack per tab it stays visible, and navigation stays isolated per tab.
5RootStackParamList and TypeScript?
Maps route names to param types. With NativeStackNavigationProp and RouteProp, TypeScript reports errors already at compile time.
6Do tab screens stay in memory?
By default yes, for instant switching and preserved scroll position. Can be disabled per screen with unmountOnBlur, saving memory but resetting state.
7Configuring deep links?
Through a linking object on NavigationContainer with prefixes and config.screens, whose nesting must exactly match the navigator structure.
8Customizing the header per screen?
screenOptions sets defaults, the options prop of an individual screen selectively overrides values like headerTitle or headerRight.
9Gesture conflict between drawer and stack?
Both claim the screen edge. Fix: disable the stack's back gesture on the topmost screen, or restrict the drawer opening area.
10Additional native modules needed?
In bare React Native yes: react-native-screens, react-native-safe-area-context, react-native-gesture-handler, react-native-reanimated, plus pod install on iOS.