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]
The resulting navigation path looks like this:
Root Stack → Drawer Navigator → Bottom Tabs → Individual Stack
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
For iOS, install pods afterward:
npx pod-install ios
Import Gesture Handler before other app imports in index.js:
import 'react-native-gesture-handler';
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>
);
}
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',
};
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>
);
}
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>
);
}
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();
};
This is the important nested-navigation call:
navigation.navigate(Routes.MainTabs, {
screen: Routes.ExploreTab,
});
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>
);
}
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();
}
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>
);
}
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},
});
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',
});
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;
}
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);
The presentational modal uses React Native's built-in Modal:
<Modal
animationType="fade"
transparent
visible={visible}
onRequestClose={onClose}>
{/* Backdrop, card, and actions */}
</Modal>
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
- 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.
-
Use
GestureHandlerRootView. It is recommended for reliable drawer and gesture behavior. - Keep one source of truth for modal behavior. The two modal approaches are both valid, but share styles and accessibility behavior where possible.
-
Wrap only the screens that need the HOC.
openModalis not global; each screen needs to be wrapped, or you can move to Context for global dialogs. - 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
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)
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.