Week 4: React Navigation Fundamentals

A React Native app with one screen isn't really an app. This week brings in React Navigation — the de facto standard for moving between screens — starting with a stack navigator and typed params, then adding tabs and a drawer, and finishing with header customization and transition options so navigation looks and feels native on each platform.

Phase 2 of 8 Week 4 of 22 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Set up a stack navigator and move between screens with navigate() and goBack()
  • Pass and read route params with full TypeScript type-checking
  • Add tab and drawer navigators alongside a stack
  • Customize headers and screen transitions per screen

1. Installing & Setting Up

React Navigation is a set of packages, not one library — the core plus whichever navigators you actually use, plus two peer dependencies almost every setup needs.

terminal
npx expo install @react-navigation/native @react-navigation/native-stack
npx expo install react-native-screens react-native-safe-area-context
App.tsx — the required wrapper
import { NavigationContainer } from '@react-navigation/native';

export default function App() {
  return (
    <NavigationContainer>
      {/* your navigator goes here */}
    </NavigationContainer>
  );
}

NavigationContainer manages the navigation tree and integrates with the device's back button on Android — every navigator in your app needs to live inside exactly one of these, at the root.

2. Stack Navigator & Params

A stack navigator is the most common navigation pattern — screens push on top of each other, with a back button (or gesture) to pop back off. createNativeStackNavigator() uses the platform's real native navigation primitives under the hood, for authentic transitions and performance.

a basic two-screen stack
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import HomeScreen from './screens/HomeScreen';
import DetailScreen from './screens/DetailScreen';

const Stack = createNativeStackNavigator();

function RootNavigator() {
  return (
    <Stack.Navigator initialRouteName="Home">
      <Stack.Screen name="Home" component={HomeScreen} />
      <Stack.Screen name="Detail" component={DetailScreen} />
    </Stack.Navigator>
  );
}
navigating with params
// HomeScreen.tsx
function HomeScreen({ navigation }) {
  return (
    <Pressable onPress={() => navigation.navigate('Detail', { itemId: 42 })}>
      <Text>Open item 42</Text>
    </Pressable>
  );
}

// DetailScreen.tsx
function DetailScreen({ route, navigation }) {
  const { itemId } = route.params;
  return (
    <View>
      <Text>Viewing item {itemId}</Text>
      <Pressable onPress={() => navigation.goBack()}>
        <Text>Go back</Text>
      </Pressable>
    </View>
  );
}

Every screen registered on a navigator automatically receives navigation and route as props — navigation.navigate(name, params) pushes a new screen, and route.params is where that screen reads whatever was passed to it.

3. Typed Navigation with TypeScript

Untyped, navigation.navigate('Detial', { itemId: 42 }) — a typo in the screen name — fails silently at runtime with no useful error. A typed param list catches that, and a missing or wrong-shaped param, at compile time instead.

navigation/types.ts
export type RootStackParamList = {
  Home: undefined;           // no params expected
  Detail: { itemId: number }; // params required, and typed
};
applying the types to Stack.Navigator
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import type { RootStackParamList } from './navigation/types';

const Stack = createNativeStackNavigator<RootStackParamList>();
a fully-typed screen component
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';

type Props = NativeStackScreenProps<RootStackParamList, 'Detail'>;

function DetailScreen({ route, navigation }: Props) {
  const { itemId } = route.params; // itemId is typed as number, no cast needed
  return <Text>Viewing item {itemId}</Text>;
}

With this in place, navigation.navigate('Detail', { itemId: 42 }) is checked against RootStackParamList at compile time — a typo in the screen name, a missing itemId, or passing a string where a number is expected all become TypeScript errors instead of silent runtime failures.

4. Tab & Drawer Navigators

Beyond a stack, React Navigation ships a bottom tab navigator and a drawer navigator — both installed and used the same way as the stack navigator, and both able to sit alongside or nest with one.

terminal
npx expo install @react-navigation/bottom-tabs @react-navigation/drawer
a bottom tab navigator with icons
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';

const Tab = createBottomTabNavigator();

function MainTabs() {
  return (
    <Tab.Navigator
      screenOptions={({ route }) => ({
        tabBarIcon: ({ color, size }) => {
          const iconName = route.name === 'Feed' ? 'home' : 'person';
          return <Ionicons name={iconName} size={size} color={color} />;
        },
      })}
    >
      <Tab.Screen name="Feed" component={FeedScreen} />
      <Tab.Screen name="Profile" component={ProfileScreen} />
    </Tab.Navigator>
  );
}

A common real-world shape — one stack navigator per tab, so pushing a detail screen from within a tab doesn't hide the tab bar unexpectedly — is genuinely nested navigation, which gets its own deeper treatment next week. For now, a single tab or drawer navigator alongside your stack is enough to see how they're configured.

5. Headers & Transitions

Every screen accepts an options prop (or a shared screenOptions on the navigator) to control its header and transition behavior, without leaving React Navigation's declarative API.

per-screen header customization
<Stack.Screen
  name="Detail"
  component={DetailScreen}
  options={({ route }) => ({
    title: `Item #${route.params.itemId}`,
    headerStyle: { backgroundColor: '#7c5cff' },
    headerTintColor: '#fff',
    headerRight: () => (
      <Pressable onPress={() => console.log('shared!')}>
        <Text style={{ color: '#fff' }}>Share</Text>
      </Pressable>
    ),
  })}
/>
a per-screen transition
<Stack.Screen
  name="Detail"
  component={DetailScreen}
  options={{
    animation: 'slide_from_right', // iOS-style push; try 'fade' or 'slide_from_bottom' too
    gestureEnabled: true,          // swipe-back gesture
  }}
/>

options can be a plain object or, as with title above, a function of {'{'} route, navigation {'}'} — the function form is what lets a header's title depend on the params the screen was navigated to with.

6. Hands-on Exercise

Hands-on

Build a 3-screen app with typed navigation

A small app combining a stack and a tab, with fully typed params throughout.

Requirements:

  1. Define a typed RootStackParamList with a Home screen (no params) and a Detail screen expecting a required {'{'} itemId: number {'}'} param.
  2. Build a Home screen listing at least 6 items; tapping one navigates to Detail with that item's ID.
  3. On Detail, type the screen's props with NativeStackScreenProps, read itemId from route.params with no manual casting, and set the header title dynamically to include the item ID.
  4. Add a custom headerRight button on Detail that logs to the console when pressed.
  5. Add a bottom tab navigator with a second tab, Settings, alongside your stack (nest the stack inside one tab, or keep them as siblings — either is fine at this stage).
Hint

If TypeScript isn't catching a typo'd screen name in navigation.navigate(), confirm you passed RootStackParamList as the generic to both createNativeStackNavigator<RootStackParamList>() and each screen's NativeStackScreenProps<RootStackParamList, 'ScreenName'> — missing it on either side silently falls back to untyped navigation.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does every navigator in a React Native app need to live inside a NavigationContainer?

NavigationContainer manages the overall navigation tree and state, and integrates with platform behavior like the Android hardware back button. Every navigator (stack, tab, drawer) needs to be nested inside exactly one container at the root of the app for navigation state to be tracked correctly.

Q2

Without a typed RootStackParamList, what happens if you call navigation.navigate('Detial', {'{'} itemId: 42 {'}'}) with a typo'd screen name?

Without types, the call fails silently at runtime — there's no screen named "Detial" so navigation simply does nothing useful, with no compile-time warning. With a typed param list applied to the navigator and every screen's props, that same typo becomes a TypeScript error before the app ever runs.

Q3

What's the difference between passing an object vs. a function to a screen's options prop?

A plain object sets fixed header/transition options that never change. A function receives {'{'} route, navigation {'}'} and can compute options dynamically — most commonly, setting a header's title based on the current screen's route.params, which a static object has no way to express.

Q4

Why does createNativeStackNavigator tend to feel more "native" than a custom hand-rolled screen transition?

It's built on top of the platform's actual native navigation primitives (UINavigationController on iOS, Fragment-based navigation on Android) rather than reimplementing transitions in JavaScript. That's what gives it authentic platform-specific gestures, transitions, and performance out of the box, matching what a fully native app would do.