- Published on
Simplifying React Navigation with Fewer Stacks and Dynamic Screens
- Authors
- Name
- Ghayoor ul Haq
React Navigation is powerful but managing complex flows with deeply nested stacks can become hard to maintain. In this post, I’ll walk you through a simple yet effective technique to reduce clutter in your navigation setup. Instead of defining all screens statically in each stack, we’ll dynamically pass components as props—keeping our navigation configuration minimal, scalable, and much easier to manage.
In typical React Native apps, especially ones with multiple tabs and modals, we tend to:
- Define a lot of stack navigators, many of which are just slight variations.
- Create deeply nested stacks to handle modals and screen flows.
- Import and register all screens statically, bloating the navigation file and hurting maintainability.
This gets worse as the app scales. Managing modal transitions, maintaining consistency, and debugging screen hierarchy becomes painful.
Let’s look at a typical multi-tab mobile app structure that includes nested screens and modals. This is how our app might look like:

This will require multiple nested stack navigators, each with its own set of screens and leads to a lot of boilerplate code and makes it hard to manage, especially when you have modals that need to be displayed over different stacks. Let's take a look how it looks like if we implement it using the traditional approach:
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
// Import all screens
import HomeScreen from './src/screens/HomeScreen';
import FeedDetailsScreen from './src/screens/FeedDetailsScreen';
import FeedModalScreen from './src/screens/FeedModalScreen';
import FeedModalDetailScreen from './src/screens/FeedModalDetailScreen';
import ProfileScreen from './src/screens/ProfileScreen';
import ProfileModalScreen from './src/screens/ProfileModalScreen';
import ChangeEmailScreen from './src/screens/ChangeEmailScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import ChangeLanguageScreen from './src/screens/ChangeLanguageScreen';
import ChangeCountryScreen from './src/screens/ChangeCountryScreen';
import ChooseCountryModalScreen from './src/screens/ChooseCountryModalScreen';
import ChooseLanguageScreen from './src/screens/ChooseLanguageScreen';
// Create navigators
const Tab = createBottomTabNavigator();
const Stack = createNativeStackNavigator();
// Modal Stack Navigator for Feed Modal
const FeedModalStack = () => {
return (
<Stack.Navigator>
<Stack.Screen name="FeedModalMain" component={FeedModalScreen} options={{ headerShown: false }} />
<Stack.Screen name="FeedModalDetail" component={FeedModalDetailScreen} options={{ headerShown: false }} />
</Stack.Navigator>
);
};
// Modal Stack Navigator for Profile Modal
const ProfileModalStack = () => {
return (
<Stack.Navigator>
<Stack.Screen name="ProfileModalMain" component={ProfileModalScreen} options={{ headerShown: false }} />
<Stack.Screen name="ChangeEmail" component={ChangeEmailScreen} options={{ headerShown: false }} />
</Stack.Navigator>
);
};
// Modal Stack Navigator for Settings Modal
const SettingsModalStack = () => {
return (
<Stack.Navigator>
<Stack.Screen name="ChooseCountryModalMain" component={ChooseCountryModalScreen} options={{ headerShown: false }} />
<Stack.Screen name="ChooseLanguage" component={ChooseLanguageScreen} options={{ headerShown: false }} />
</Stack.Navigator>
);
};
// Home Stack Navigator
const HomeStack = () => {
return (
<Stack.Navigator>
<Stack.Screen name="Feeds" component={HomeScreen} />
<Stack.Screen name="FeedDetails" component={FeedDetailsScreen} />
<Stack.Group screenOptions={{ presentation: 'modal' }}>
<Stack.Screen name="FeedModal" component={FeedModalStack} options={{ headerShown: false }} />
</Stack.Group>
</Stack.Navigator>
);
};
// Profile Stack Navigator
const ProfileStack = () => {
return (
<Stack.Navigator>
<Stack.Screen name="Profile" component={ProfileScreen} />
<Stack.Group screenOptions={{ presentation: 'modal' }}>
<Stack.Screen name="ProfileModal" component={ProfileModalStack} options={{ headerShown: false }} />
</Stack.Group>
</Stack.Navigator>
);
};
// Settings Stack Navigator
const SettingsStack = () => {
return (
<Stack.Navigator>
<Stack.Screen name="Settings" component={SettingsScreen} />
<Stack.Screen name="ChangeLanguage" component={ChangeLanguageScreen} />
<Stack.Screen name="ChangeCountry" component={ChangeCountryScreen} />
<Stack.Group screenOptions={{ presentation: 'modal' }}>
<Stack.Screen name="ChooseCountryModal" component={SettingsModalStack} options={{ headerShown: false }} />
</Stack.Group>
</Stack.Navigator>
);
};
// Main Tab Navigator
const TabNavigator = () => {
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarShowLabel: true,
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: '#8E8E93',
}}
>
<Tab.Screen
name="HomeTab"
component={HomeStack}
options={{
tabBarLabel: 'Home',
}}
/>
<Tab.Screen
name="ProfileTab"
component={ProfileStack}
options={{
tabBarLabel: 'Profile',
}}
/>
<Tab.Screen
name="SettingsTab"
component={SettingsStack}
options={{
tabBarLabel: 'Settings',
}}
/>
</Tab.Navigator>
);
};
// Main App Component
const App = () => {
return (
<NavigationContainer>
<TabNavigator />
</NavigationContainer>
);
};
export default App;
The Solution
To simplify this, we can use a dynamic screen approach. Instead of defining each screen statically, we can pass components as props to our stack navigators. This allows us to keep our navigation structure clean and flexible. From example above, instead of defining lots of stack navigations with all possible screens app can have, we can have 3 stacks only.
- Tab Navigator: Contains the main tabs like Home, Profile, and Settings.
- Screen Stack Navigator: Handles screen navigation in any tab.
- Modal Stack Navigator: Handles all modals from any tab.
Lets implement this approach step by step.
First of all we need to create navigators for tabs and stack:
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
import {createNativeStackNavigator} from '@react-navigation/native-stack';
const Tab = createBottomTabNavigator();
const Stack = createNativeStackNavigator();
Tab Navigator
Our Tab Navigator will remain mostly the same, but instead of passing a stack navigator to each tab (like HomeStack), we now pass the screen component directly such as passing HomeScreen directly to the Home tab.
// Import Tab screens
import HomeScreen from './src/screens/HomeScreen';
import ProfileScreen from './src/screens/ProfileScreen';
import SettingsScreen from './src/screens/SettingsScreen';
const TabNavigator = () => {
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarShowLabel: true,
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: '#8E8E93',
}}>
<Tab.Screen
name="HomeTab"
component={HomeScreen}
options={{
tabBarLabel: 'Home',
}}
/>
<Tab.Screen
name="ProfileTab"
component={ProfileScreen}
options={{
tabBarLabel: 'Profile',
}}
/>
<Tab.Screen
name="SettingsTab"
component={SettingsScreen}
options={{
tabBarLabel: 'Settings',
}}
/>
</Tab.Navigator>
);
};
Screen and Modal Stack Navigators
Next, we create a Screen Stack Navigator that will handle all the screens navigations in each tab.
First, let’s create an object with two properties screen
and options
, both defined as functions. The screen function will render the component passed via props, while the options function will dynamically return the screen options based on those props.
const createRouteScreen = {
component: ({route}) => {
const {component: Component, params} = route.params;
return <Component {...params} />;
},
options: ({route}) => ({
title: route?.params?.title || ''
})
};
Now we can create a Screen Stack Navigator that will handle all the screens navigations in each tab.
const ScreenStackNavigator = () => {
return (
<Stack.Navigator>
<Stack.Screen
name="Screen"
component={createRouteScreen.component}
options={createRouteScreen.options}
/>
</Stack.Navigator>
);
};
Let's create a Modal Stack Navigator that will handle all the modals navigations in each tab.
const ModalStackNavigator = () => {
return (
<Stack.Navigator screenOptions={{headerShown: false}}>
<Stack.Screen
name="Modal"
component={createRouteScreen.component}
options={createRouteScreen.options}
/>
<Stack.Screen
name="ModalScreen"
component={createRouteScreen.component}
options={createRouteScreen.options}
/>
</Stack.Navigator>
);
};
We intentionally defined both Modal and ModalScreen to ensure smooth transition animations between modal screens. If we only use a single Modal route, navigating from one modal screen (e.g., Feed Modal) to another (e.g., Feed Modal Detail) will skip the transition animation.
Main App Component
Finally, we can create our main App component that will render the Tab Navigator and the Screen and Modal Stack Navigators.
const App = () => {
return (
<NavigationContainer>
<Stack.Navigator screenOptions={{headerShown: false}}>
<Stack.Screen name="Tabs" component={TabNavigator} />
<Stack.Screen name="ScreenStack" component={ScreenStackNavigator} />
<Stack.Group
screenOptions={{
presentation: 'modal',
animation: 'slide_from_bottom',
headerShown: false
}}>
<Stack.Screen name="ModalStack" component={ModalStackNavigator} />
</Stack.Group>
</Stack.Navigator>
</NavigationContainer>
);
};
export default App;
This setup allows us to dynamically pass any screen component to the ScreenStackNavigator
or ModalStackNavigator
without needing to define each screen statically.
Usage
Whenever we want to navigate to a screen, lets say from Home tab
to Feed Details
.
Import FeedDetails
component because we need to navigate to it.
import FeedDetails from '../screens/FeedDetailsScreen';
Simply dispatch a navigation action with the FeedDetails
in param as component to the Screen
in ScreenStack
.:
navigation.dispatch(
StackActions.push('ScreenStack', {
screen: 'Screen',
params: {
component: FeedDetails
}
}),
);
navigation.dispatch(StackActions.push(...))
programmatically pushes a new screen onto the navigation stack, even within nested navigators, allowing you to navigate forward with custom parameters.
Same goes for modal, when we want to navigate to a modal from screen, lets say from Profile tab
to Profile Modal
. Import ProfileModal
component because we need to navigate to it and dispatch a navigation action with the ProfileModal
in param as component to the Modal
in ModalStack
.
import ProfileModalScreen from './ProfileModalScreen.tsx';
...
navigation.dispatch(
StackActions.push('ModalStack', {
screen: 'Modal',
params: {
component: ProfileModalScreen
}
}),
);
If we want to navigate to a modal screen from a modal, lets say from Feed Modal
to Feed Modal Detail
, no need to call navigation action because its same stack but different and here ModalScreen play important role to navigate to another modal screen with transition animation. So this is how we do it:
import FeedModalDetailScreen from './FeedModalDetailScreen.tsx';
...
navigation.navigate('ModalScreen', {
component: FeedModalDetailScreen
});
Icing on the Cake
You can pass screen options as props from navigation action to customize the screen title, show/hide header, or any other options dynamically. For example, if you want to set a custom title for the FeedDetails
screen:
Pass the title
in params when navigating:
navigation.dispatch(
StackActions.push('ScreenStack', {
screen: 'Screen',
params: {
component: FeedDetails,
title: 'Feed Details'
}
}),
);
Then, in the createRouteScreen
options function, we can access this title and set it accordingly:
const createRouteScreen = {
component: ({route}) => {
const {component: Component, params} = route.params;
return <Component {...params} />;
},
options: ({route}) => ({
title: route?.params?.title || ''
})
};
This way, you can dynamically set the title for each screen without hardcoding it in the navigation configuration.
Note: As of this writing, React Navigation is currently at version 7.x.
I’d appreciate any feedback on the approach outlined particularly any potential disadvantages or limitations you might see.
Links
Check links to see the full code of example on GitHub: