DEV Community

Cover image for React Native Navigation: Bottom Tabs, Drawer, Nested Stacks, and a Reusable Modal HOC
Amit Kumar
Amit Kumar

Posted on

React Native Navigation: Bottom Tabs, Drawer, Nested Stacks, and a Reusable Modal HOC

React Native Navigation: Bottom Tabs, Drawer, Nested Stacks, and a Reusable Modal HOC

Most production React Native apps need more than one navigator. A common structure is:

  • a splash screen at the app root;
  • a drawer for top-level destinations;
  • bottom tabs for the main sections;
  • a stack inside each tab for detail screens; and
  • reusable dialogs that do not require a navigation route.

This article walks through that architecture and explains when to use a navigation modal versus a reusable Higher-Order Component (HOC) modal.

What we are building

flowchart TD
  App[App] --> Root[Root Stack]
  Root --> Splash[Splash]
  Root --> Drawer[Drawer Navigator]
  Root --> RouteModal[Modal route]
  Drawer --> Tabs[Bottom Tab Navigator]
  Tabs --> Home[Home Stack]
  Tabs --> Explore[Explore Stack]
  Tabs --> Favorites[Favorites Stack]
  Tabs --> Profile[Profile Stack]
  Home --> HomeScreen[Home]
  Home --> Details[Details]
Enter fullscreen mode Exit fullscreen mode

The resulting navigation path looks like this:

Root Stack  Drawer Navigator  Bottom Tabs  Individual Stack
Enter fullscreen mode Exit fullscreen mode

This keeps global navigation concerns outside of feature-specific detail screens.

1. Install the required packages

Install React Navigation and the native dependencies:

npm install @react-navigation/native \
  @react-navigation/native-stack \
  @react-navigation/bottom-tabs \
  @react-navigation/drawer \
  react-native-gesture-handler \
  react-native-reanimated \
  react-native-screens \
  react-native-safe-area-context
Enter fullscreen mode Exit fullscreen mode

For iOS, install pods afterward:

npx pod-install ios
Enter fullscreen mode Exit fullscreen mode

Import Gesture Handler before other app imports in index.js:

import 'react-native-gesture-handler';
Enter fullscreen mode Exit fullscreen mode

Also wrap the application with GestureHandlerRootView. It avoids gesture problems with drawers, especially on Android:

import {GestureHandlerRootView} from 'react-native-gesture-handler';

export default function App() {
  return (
    <GestureHandlerRootView style={{flex: 1}}>
      <RootNavigator />
    </GestureHandlerRootView>
  );
}
Enter fullscreen mode Exit fullscreen mode

Ensure the Reanimated Babel plugin required by your installed Reanimated version is configured as the final Babel plugin. Check the current Reanimated installation guide because the setup differs by version.

2. Centralize route names

Nested navigation becomes easier to maintain when route names live in one file.

export const Routes = {
  Splash: 'Splash',
  MainDrawer: 'MainDrawer',
  MainTabs: 'MainTabs',
  Modal: 'Modal',

  HomeTab: 'HomeTab',
  ExploreTab: 'ExploreTab',
  FavoritesTab: 'FavoritesTab',
  ProfileTab: 'ProfileTab',

  Home: 'Home',
  Details: 'Details',
  Explore: 'Explore',
  ExploreDetails: 'ExploreDetails',
  Favorites: 'Favorites',
  FavoriteDetails: 'FavoriteDetails',
  Profile: 'Profile',
  Settings: 'Settings',
};
Enter fullscreen mode Exit fullscreen mode

Using constants prevents subtle bugs caused by route-name typos.

3. Create a stack for each tab

Each tab owns its own history. For example, the Explore tab can open an Explore Details screen without affecting the history of Home or Favorites.

import {createNativeStackNavigator} from '@react-navigation/native-stack';
import {Routes} from '../../constants';
import {ExploreScreen, ExploreDetailsScreen} from '../../screens';

const Stack = createNativeStackNavigator();

export function ExploreStackNavigator() {
  return (
    <Stack.Navigator screenOptions={{headerShown: false}}>
      <Stack.Screen name={Routes.Explore} component={ExploreScreen} />
      <Stack.Screen
        name={Routes.ExploreDetails}
        component={ExploreDetailsScreen}
      />
    </Stack.Navigator>
  );
}
Enter fullscreen mode Exit fullscreen mode

Repeat this pattern for Home, Favorites, and Profile.

4. Add the bottom tab navigator

The tab navigator hosts the four feature stacks. In this app, it also owns a shared custom header.

import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
import {Routes} from '../constants';
import {
  HomeStackNavigator,
  ExploreStackNavigator,
  FavoritesStackNavigator,
  ProfileStackNavigator,
} from './stacks';
import {AppHeader} from './AppHeader';

const Tab = createBottomTabNavigator();

export function TabNavigator() {
  return (
    <Tab.Navigator
      screenOptions={{
        headerShown: true,
        header: props => <AppHeader {...props} />,
        tabBarHideOnKeyboard: true,
      }}>
      <Tab.Screen
        name={Routes.HomeTab}
        component={HomeStackNavigator}
        options={{title: 'Home'}}
      />
      <Tab.Screen
        name={Routes.ExploreTab}
        component={ExploreStackNavigator}
        options={{title: 'Explore'}}
      />
      <Tab.Screen
        name={Routes.FavoritesTab}
        component={FavoritesStackNavigator}
        options={{title: 'Favorites'}}
      />
      <Tab.Screen
        name={Routes.ProfileTab}
        component={ProfileStackNavigator}
        options={{title: 'Profile'}}
      />
    </Tab.Navigator>
  );
}
Enter fullscreen mode Exit fullscreen mode

Each tab now has independent stack state. A user can open a detail screen in Explore, switch to Favorites, then return to the same Explore detail screen.

5. Put tabs inside a custom drawer

The drawer contains one screen—MainTabs—but its custom content can switch the nested active tab.

const openTab = route => {
  navigation.navigate(Routes.MainTabs, {screen: route});
  navigation.closeDrawer();
};
Enter fullscreen mode Exit fullscreen mode

This is the important nested-navigation call:

navigation.navigate(Routes.MainTabs, {
  screen: Routes.ExploreTab,
});
Enter fullscreen mode Exit fullscreen mode

A simple drawer setup looks like this:

import {createDrawerNavigator} from '@react-navigation/drawer';
import {Routes} from '../constants';
import {TabNavigator} from './TabNavigator';

const Drawer = createDrawerNavigator();

export function DrawerNavigator() {
  return (
    <Drawer.Navigator
      drawerContent={props => <CustomDrawerContent {...props} />}
      screenOptions={{
        headerShown: false,
        drawerType: 'front',
        swipeEdgeWidth: 80,
      }}>
      <Drawer.Screen name={Routes.MainTabs} component={TabNavigator} />
    </Drawer.Navigator>
  );
}
Enter fullscreen mode Exit fullscreen mode

Open the drawer from a tab header

The tab navigator is a child of the drawer navigator. Therefore, the header can call getParent() to access drawer methods:

function openMenu(navigation) {
  navigation.getParent()?.openDrawer();
}
Enter fullscreen mode Exit fullscreen mode

Use optional chaining only when a missing parent is an acceptable failure. For a required navigator relationship, validating the tree during development is better than silently doing nothing.

6. Add a root stack around everything

The root navigator contains the splash screen, the main drawer application, and route-based modals.

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

const Stack = createNativeStackNavigator();

export function RootNavigator() {
  return (
    <NavigationContainer>
      <Stack.Navigator screenOptions={{headerShown: false}}>
        <Stack.Screen name={Routes.Splash} component={SplashScreen} />
        <Stack.Screen name={Routes.MainDrawer} component={DrawerNavigator} />
        <Stack.Screen
          name={Routes.Modal}
          component={ModalScreen}
          options={{
            presentation: 'transparentModal',
            animation: 'fade',
            contentStyle: {backgroundColor: 'transparent'},
          }}
        />
      </Stack.Navigator>
    </NavigationContainer>
  );
}
Enter fullscreen mode Exit fullscreen mode

The splash screen can enter a specific nested destination with replace, so users cannot navigate back to splash:

navigation.replace(Routes.MainDrawer, {
  screen: Routes.MainTabs,
  params: {screen: Routes.HomeTab},
});
Enter fullscreen mode Exit fullscreen mode

7. Open a navigation modal from the drawer

The drawer is nested inside the root stack. To open the root-level modal, move up one level:

navigation.getParent()?.navigate(Routes.Modal, {
  eyebrow: 'NEED A HAND?',
  title: 'How can we help?',
  message: 'Browse the app from the drawer or contact support.',
  confirmLabel: 'Got it',
});
Enter fullscreen mode Exit fullscreen mode

Use a navigation modal when it is a destination in your app flow—for example, an onboarding step, a paywall, or a screen that needs route parameters and back-navigation behavior.

8. Build a reusable modal HOC

Not every dialog needs to be a screen in the navigation tree. Confirmation dialogs, short feedback messages, and quick actions are often better as local UI state.

The HOC below injects openModal and closeModal into a wrapped screen:

import React, {useCallback, useState} from 'react';
import {AppModal} from './AppModal';

const INITIAL_MODAL = {
  visible: false,
  eyebrow: 'QUICK ACTION',
  title: 'Are you sure?',
  message: '',
  confirmLabel: 'Continue',
  cancelLabel: 'Not now',
  icon: '',
  onConfirm: undefined,
};

export function withModal(WrappedComponent) {
  function ComponentWithModal(props) {
    const [modal, setModal] = useState(INITIAL_MODAL);

    const closeModal = useCallback(() => {
      setModal(current => ({...current, visible: false}));
    }, []);

    const openModal = useCallback((options = {}) => {
      setModal({...INITIAL_MODAL, ...options, visible: true});
    }, []);

    const confirmModal = useCallback(() => {
      modal.onConfirm?.();
      closeModal();
    }, [closeModal, modal]);

    return (
      <>
        <WrappedComponent
          {...props}
          openModal={openModal}
          closeModal={closeModal}
        />
        <AppModal
          {...modal}
          onClose={closeModal}
          onConfirm={confirmModal}
        />
      </>
    );
  }

  return ComponentWithModal;
}
Enter fullscreen mode Exit fullscreen mode

Wrap a screen when exporting it:

function HomeScreen({openModal}) {
  return (
    <Button
      title="Show modal"
      onPress={() =>
        openModal({
          title: 'Reusable HOC modal',
          message: 'This dialog is controlled by the screen.',
          confirmLabel: 'Great',
        })
      }
    />
  );
}

export default withModal(HomeScreen);
Enter fullscreen mode Exit fullscreen mode

The presentational modal uses React Native's built-in Modal:

<Modal
  animationType="fade"
  transparent
  visible={visible}
  onRequestClose={onClose}>
  {/* Backdrop, card, and actions */}
</Modal>
Enter fullscreen mode Exit fullscreen mode

Navigation modal vs HOC modal

Use a root navigation modal when… Use the HOC modal when…
It is part of the navigation flow. It is local UI feedback or a confirmation.
It needs route parameters or a deep-linkable destination. The screen simply needs openModal() state.
Back navigation should dismiss it. You want to avoid adding a route for a small dialog.
Multiple features must navigate to it consistently. The behavior is scoped to a particular wrapped screen.

In this project, the help dialog is a root navigation modal, while the Home screen uses the reusable HOC modal.

Important improvements before production

  1. Provide back navigation for detail screens. If the header is owned by the tab navigator while nested stacks hide their headers, detail screens need their own visible back action.
  2. Use GestureHandlerRootView. It is recommended for reliable drawer and gesture behavior.
  3. Keep one source of truth for modal behavior. The two modal approaches are both valid, but share styles and accessibility behavior where possible.
  4. Wrap only the screens that need the HOC. openModal is not global; each screen needs to be wrapped, or you can move to Context for global dialogs.
  5. Use an imperative navigation service carefully. A navigation ref is useful for push notifications and auth redirects, but screen components should normally use the navigation prop or hook.

Final structure

src/
├── constants/
│   └── routes.js
├── components/
│   └── AppModal/
│       ├── AppModal.js
│       └── withModal.js
├── navigation/
│   ├── RootNavigator.js
│   ├── DrawerNavigator.js
│   ├── TabNavigator.js
│   └── stacks/
│       ├── HomeStack.js
│       ├── ExploreStack.js
│       ├── FavoritesStack.js
│       └── ProfileStack.js
└── screens/
    └── Modal/
        └── ModalScreen.js
Enter fullscreen mode Exit fullscreen mode

This architecture scales well because each layer has one responsibility: the root owns application flow, the drawer exposes major destinations, tabs organize primary sections, stacks handle local history, and the HOC handles local dialogs.


What navigator combination are you using in your React Native app: tabs and stacks only, or a drawer as well?

Top comments (1)

Collapse
 
nazar-boyko profile image
Nazar Boyko

Tapping the active tab again is the surprise users will find here. With independent stacks, someone deep in ExploreDetails taps the Explore icon expecting to go back and nothing happens. A tabPress listener that pops the stack to top makes it behave like most native apps.