GHAYOOR
Published on

Simplifying React Navigation with Fewer Stacks and Dynamic Screens

Authors
  • avatar
    Name
    Ghayoor ul Haq
    Twitter

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:

Structure

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.

Check links to see the full code of example on GitHub: