Why Stack and Tab Patterns Matter in Real Apps
Most mobile apps combine two navigation patterns: a stack for moving forward and backward through related screens (like a drill-down flow), and tabs for switching between top-level sections (like Home, Search, Profile). Implementing these patterns well is less about “making navigation work” and more about shaping how users move through your app without getting lost.
In React Native, these patterns are typically implemented with React Navigation. You will usually compose navigators: a tab navigator at the root, with each tab containing its own stack navigator. This gives each section an independent history, so switching tabs doesn’t reset where the user was inside that section.
Installing and Wiring React Navigation for Stack + Tabs
This chapter focuses on implementing stack and tab patterns, assuming you already understand what screens and routes are. To build stack and tab navigators, you’ll use these packages (versions may vary):
@react-navigation/native(core)@react-navigation/native-stack(stack implementation)@react-navigation/bottom-tabs(tab implementation)- Dependencies required by React Navigation (such as
react-native-screens,react-native-safe-area-context)
After installing, you must wrap your app in a NavigationContainer. This is the root context provider that holds navigation state.
import * as React from 'react';
import { NavigationContainer } from '@react-navigation/native';
export default function App() {
return (
<NavigationContainer>
{/* Your navigators go here */}
</NavigationContainer>
);
}From here, you will create navigators and register screens. The key idea is: navigators are components. You can nest them like any other component tree.
- Listen to the audio with the screen off.
- Earn a certificate upon completion.
- Over 5000 courses for you to explore!
Download the app
Implementing a Basic Stack Navigator
A stack navigator models a push/pop history. When you navigate to a new screen, it is pushed on top; when you go back, it is popped off.
Step 1: Create the stack and register screens
import * as React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import HomeScreen from './screens/HomeScreen';
import DetailsScreen from './screens/DetailsScreen';
const Stack = createNativeStackNavigator();
export function HomeStack() {
return (
<Stack.Navigator initialRouteName="Home">
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Details" component={DetailsScreen} />
</Stack.Navigator>
);
}initialRouteName sets the first screen. Each Stack.Screen has a name (route name) and a component (React component to render).
Step 2: Navigate between stack screens
Inside HomeScreen, use navigation.navigate to push a new screen.
import * as React from 'react';
import { View, Button } from 'react-native';
export default function HomeScreen({ navigation }) {
return (
<View>
<Button
title="Go to details"
onPress={() => navigation.navigate('Details', { itemId: 42 })}
/>
</View>
);
}In DetailsScreen, read params and implement back behavior.
import * as React from 'react';
import { View, Text, Button } from 'react-native';
export default function DetailsScreen({ route, navigation }) {
const { itemId } = route.params ?? {};
return (
<View>
<Text>Item: {itemId}</Text>
<Button title="Go back" onPress={() => navigation.goBack()} />
</View>
);
}Practical tip: prefer goBack() when you truly want to pop the current screen. Prefer navigate() when you want to ensure a specific route is active (especially across nested navigators).
Customizing Stack Headers and Screen Options
Stack navigators typically show a header with a title and back button. You can configure headers globally on the navigator or per screen.
Global options
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: '#111827' },
headerTintColor: '#fff',
headerTitleStyle: { fontWeight: '600' },
}}
>
<Stack.Screen name="Home" component={HomeScreen} options={{ title: 'Dashboard' }} />
<Stack.Screen name="Details" component={DetailsScreen} />
</Stack.Navigator>Dynamic titles based on params
When the header title depends on route params, use an options function.
<Stack.Screen
name="Details"
component={DetailsScreen}
options={({ route }) => ({
title: route.params?.title ?? 'Details',
})}
/>Practical tip: keep header logic close to the navigator where possible. It makes screen components simpler and avoids mixing layout concerns with business logic.
Implementing a Bottom Tab Navigator
Tabs are best for switching between peer sections. A bottom tab navigator renders a bar at the bottom with icons/labels and swaps the active screen when a tab is pressed.
Step 1: Create the tab navigator
import * as React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import FeedScreen from './screens/FeedScreen';
import SearchScreen from './screens/SearchScreen';
import ProfileScreen from './screens/ProfileScreen';
const Tab = createBottomTabNavigator();
export function RootTabs() {
return (
<Tab.Navigator>
<Tab.Screen name="Feed" component={FeedScreen} />
<Tab.Screen name="Search" component={SearchScreen} />
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>
);
}Step 2: Configure tab appearance
Tab options commonly include label visibility, active/inactive colors, and header behavior.
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#2563eb',
tabBarInactiveTintColor: '#6b7280',
tabBarStyle: { backgroundColor: '#fff' },
}}
>
<Tab.Screen name="Feed" component={FeedScreen} />
<Tab.Screen name="Search" component={SearchScreen} />
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>Practical tip: if each tab contains its own stack (common), you often set headerShown: false on the tabs and let each stack manage its own header.
Composing Tabs with Stacks (Most Common Pattern)
A typical structure is:
- Root: Bottom Tabs
- Each Tab: Stack Navigator
- Some flows: Modal stack on top of tabs (optional)
This composition gives each tab its own history. For example, in the Feed tab you might drill into an article, then switch to Search, then return to Feed and still be on that article.
Step-by-step: Build stacks for each tab
import * as React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import FeedScreen from './screens/FeedScreen';
import PostScreen from './screens/PostScreen';
const FeedStackNav = createNativeStackNavigator();
export function FeedStack() {
return (
<FeedStackNav.Navigator>
<FeedStackNav.Screen name="FeedHome" component={FeedScreen} options={{ title: 'Feed' }} />
<FeedStackNav.Screen name="Post" component={PostScreen} />
</FeedStackNav.Navigator>
);
}Create another stack for Profile:
import * as React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import ProfileScreen from './screens/ProfileScreen';
import SettingsScreen from './screens/SettingsScreen';
const ProfileStackNav = createNativeStackNavigator();
export function ProfileStack() {
return (
<ProfileStackNav.Navigator>
<ProfileStackNav.Screen name="ProfileHome" component={ProfileScreen} options={{ title: 'Profile' }} />
<ProfileStackNav.Screen name="Settings" component={SettingsScreen} />
</ProfileStackNav.Navigator>
);
}Step-by-step: Use stacks as tab screens
import * as React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { FeedStack } from './navigation/FeedStack';
import { ProfileStack } from './navigation/ProfileStack';
import SearchScreen from './screens/SearchScreen';
const Tab = createBottomTabNavigator();
export function RootTabs() {
return (
<Tab.Navigator screenOptions={{ headerShown: false }}>
<Tab.Screen name="FeedTab" component={FeedStack} options={{ title: 'Feed' }} />
<Tab.Screen name="SearchTab" component={SearchScreen} options={{ title: 'Search' }} />
<Tab.Screen name="ProfileTab" component={ProfileStack} options={{ title: 'Profile' }} />
</Tab.Navigator>
);
}Notice the naming: tab route names are FeedTab, ProfileTab, while stack route names inside are FeedHome, Post, ProfileHome, Settings. This avoids confusion when navigating across nested navigators.
Navigating Between Nested Navigators
When you nest navigators, you often need to navigate to a screen inside another navigator. React Navigation supports this by letting you specify a parent route and a target screen.
Example: From Feed to Profile Settings
Imagine you’re on a Feed screen and want to take the user directly to Settings inside the Profile stack.
// From any screen that has access to the root tab navigator
navigation.navigate('ProfileTab', {
screen: 'Settings',
});If the target screen is deeper (for example, Settings has its own nested screens), you can continue nesting screen and params.
Example: Navigate to a Post with params
navigation.navigate('FeedTab', {
screen: 'Post',
params: { postId: 'abc123' },
});Practical tip: keep route names stable and centralized. Many teams create a small routes object to avoid typos and make refactors safer.
Controlling Back Behavior and History
Stack history can become confusing if you always use navigate without thinking about whether you want a new instance of a screen or to return to an existing one.
Use cases and the right method
- Go back one screen:
navigation.goBack() - Return to the first screen in the stack:
navigation.popToTop() - Go back multiple screens:
navigation.pop(2) - Replace current screen (no back to previous):
navigation.replace('RouteName')
replace is useful after completing a flow where returning would be confusing, such as after a successful checkout step or after finishing onboarding.
Resetting navigation state (advanced but practical)
Sometimes you want to clear history and start fresh, such as after logout. React Navigation provides reset actions. A common approach is to reset to a specific route.
import { CommonActions } from '@react-navigation/native';
navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [{ name: 'FeedTab' }],
})
);Use resets carefully. They are powerful, but they can also make the app feel unpredictable if used too often.
Tab Behavior: Preserving State, Unmounting, and Reselect Actions
By default, tab screens remain mounted so their state is preserved when switching tabs. This is usually what you want for performance and user experience, but there are cases where you may want to unmount a tab when it loses focus (for example, to clear a form).
Unmount on blur
<Tab.Screen
name="SearchTab"
component={SearchScreen}
options={{ unmountOnBlur: true, title: 'Search' }}
/>Practical tip: unmounting can increase re-render work and may feel slower. Prefer explicit state clearing unless you truly need a full reset.
Handling tab reselect (scroll to top pattern)
A common UX pattern: if the user taps the active tab again, you scroll a list to top or pop to the first screen of that tab’s stack. You can implement this by listening to tab press events in the stack’s root screen or by using navigation listeners.
import * as React from 'react';
export default function FeedScreen({ navigation }) {
React.useEffect(() => {
const unsubscribe = navigation.addListener('tabPress', (e) => {
// If you want to prevent default behavior, you can call e.preventDefault()
// Example: pop to top when reselecting the tab
navigation.popToTop?.();
});
return unsubscribe;
}, [navigation]);
return null;
}Note: popToTop exists on stack navigation objects. If your screen is not inside a stack, you’ll implement a different behavior (like scrolling a list ref to top).
Adding Icons to Tabs (Common Implementation Detail)
Tabs are much easier to scan with icons. React Navigation doesn’t ship icons; you typically use an icon library. The key is to implement tabBarIcon in screen options.
<Tab.Screen
name="FeedTab"
component={FeedStack}
options={{
title: 'Feed',
tabBarIcon: ({ color, size }) => (
// Replace with your icon component
// <Icon name="home" color={color} size={size} />
null
),
}}
/>Practical tip: keep icon rendering lightweight. The tab bar renders frequently, and heavy icon components can affect responsiveness on lower-end devices.
Modal Screens on Top of Tabs (Stack Above Tabs)
Another common pattern is to have tabs as the main app shell, but present certain screens modally (for example, “Create Post”, “Filters”, “Edit Profile”). A clean way to do this is to create a root stack that contains the tabs plus modal screens.
Step-by-step: Root stack with modal group
import * as React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { RootTabs } from './RootTabs';
import CreatePostScreen from '../screens/CreatePostScreen';
const RootStack = createNativeStackNavigator();
export function RootNavigator() {
return (
<RootStack.Navigator>
<RootStack.Screen
name="MainTabs"
component={RootTabs}
options={{ headerShown: false }}
/>
<RootStack.Group screenOptions={{ presentation: 'modal' }}>
<RootStack.Screen name="CreatePost" component={CreatePostScreen} />
</RootStack.Group>
</RootStack.Navigator>
);
}Now, from anywhere inside the tabs, you can open the modal:
navigation.navigate('CreatePost');This keeps your main tab structure intact while allowing special screens to appear in a different presentation style.
Type-Safe Navigation (Recommended for Larger Apps)
If you use TypeScript, you can strongly type route names and params to catch mistakes at compile time. This becomes especially valuable with nested navigators.
Define param lists
export type FeedStackParamList = {
FeedHome: undefined;
Post: { postId: string };
};
export type ProfileStackParamList = {
ProfileHome: undefined;
Settings: undefined;
};
export type RootTabParamList = {
FeedTab: undefined;
SearchTab: undefined;
ProfileTab: undefined;
};Then apply types to navigators and screen props (exact typing approach depends on your setup). The goal is: if you try to navigate to Post without postId, TypeScript warns you.
Practical Checklist for Implementing Stack + Tabs Cleanly
- Use tabs for top-level sections and stacks for drill-down flows inside each section.
- Name routes clearly to avoid collisions between tab routes and stack routes.
- Let stacks own headers when tabs contain stacks; hide the tab header to avoid double headers.
- Use nested navigation syntax (
navigate(parent, { screen, params })) for cross-tab deep links. - Choose back behavior intentionally (
goBackvsreplacevsreset). - Be cautious with unmountOnBlur; preserve state by default unless you have a reason not to.
- Consider a root modal stack for screens that should appear above tabs (create/edit/filter flows).