Navigation in a Mobile App

Why a phone app has no address bar, what a navigator actually owns, and the three screen shapes users already expect.

A web page has an address bar and a history stack the browser maintains for you. A phone app has neither. There is no window.location here and no history object. Which screen is on top is held in an ordinary JavaScript value called the navigation state, and the component that owns that value and draws the matching screen is a navigator.

You can write one yourself with useState, and the first example does. It works, and it is still wrong, because a navigator also supplies the header, the push and pop animation, screen reuse and the platform back behaviour. React Native ships no navigator in the box. The library almost everyone uses is React Navigation, and Expo Router, covered next, is a file-based layer on top of it.

Three shapes

  • Stack — screens pile up, the newest covers the rest, going back pops the top one. Details, flows, anything with a deeper level.
  • Tabs — a fixed set of sections, switched rather than stacked, each keeping its own state.
  • Drawer — a panel sliding in from the edge, for sections that do not deserve a tab.

Back is a contract

Android has a system back gesture, and a hardware button on older devices; both fire whether your screen is ready or not. iOS has no back button hardware at all, only the swipe from the left edge, and only inside a stack. A screen that swallows back is read as broken, so treat back as part of the design rather than something to intercept later.

Example

Example · tsx
// App.tsx - two screens with no navigator, to show what a navigator does for you.
// On screen: a centred "Orders" title with a link beneath it; tapping the link
// swaps the whole screen for the order detail, with no animation and no header.
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';

type ScreenName = 'list' | 'detail';

export default function App() {
  // The entire navigation state of this app: one string.
  const [screen, setScreen] = useState<ScreenName>('list');

  if (screen === 'list') {
    return (
      <View style={styles.container}>
        <Text style={styles.title}>Orders</Text>
        <Pressable onPress={() => setScreen('detail')}>
          <Text style={styles.link}>Open order 1042</Text>
        </Pressable>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Order 1042</Text>
      {/* Hand-written back. No header, no transition, and the Android system
          back gesture does not come here - it closes the app instead. */}
      <Pressable onPress={() => setScreen('list')}>
        <Text style={styles.link}>Back</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 },
  title: { fontSize: 22, fontWeight: '600' },
  link: { fontSize: 16, color: '#0a7ea4' },
});

When to use it

  • Deciding whether a new section belongs in a tab, a stack push or a drawer before writing any of it.
  • Explaining to a reviewer why a useState screen switch stops scaling at the third screen.
  • Auditing an app where the Android back gesture closes it from the middle of a multi-step flow.

More examples

Navigation state is just data

Push, pop and replace are three operations on an array, nothing more. Reach for this when someone asks what replace really does: it drops the top entry before adding the new one, so the screen you came from is gone while everything below it stays — which is why replacing the only screen in the stack leaves no back arrow, while replacing a screen that was pushed onto another still has one, exactly as the pop above shows. Note that the arrow function with an implicit return body is the same thing as writing a block with a return statement in it.

Example · tsx
// A navigator's state is a value you could have written yourself.
// This is the shape, reduced to its essentials.
type Route = { name: string };

type Action =
  | { type: 'push'; name: string }
  | { type: 'pop' }
  | { type: 'replace'; name: string };

function navigate(stack: Route[], action: Action): Route[] {
  switch (action.type) {
    case 'push':
      return [...stack, { name: action.name }];
    case 'pop':
      // Never pop the last entry: an empty stack has nothing to draw.
      return stack.length > 1 ? stack.slice(0, -1) : stack;
    case 'replace':
      return [...stack.slice(0, -1), { name: action.name }];
  }
}

const names = (stack: Route[]) => stack.map((route) => route.name).join(' > ');

let stack: Route[] = [{ name: 'orders' }];
stack = navigate(stack, { type: 'push', name: 'order/1042' });
console.log(names(stack));
// Output: orders > order/1042

stack = navigate(stack, { type: 'replace', name: 'order/1043' });
console.log(names(stack));
// Output: orders > order/1043

console.log(names(navigate(stack, { type: 'pop' })));
// Output: orders

The same two screens as Expo Router files

Three lines of configuration replace the hand-rolled switch, and the back arrow, the slide-in animation and the Android back gesture all start working. Use this as the starting point of any new app: the root layout comes first, screens after.

Example · tsx
// app/_layout.tsx
// The same app, handed to a navigator. Expo Router now owns the stack, the
// header, the transition and the platform back behaviour.
//
// app/
//   _layout.tsx      this file - a layout, not a screen
//   index.tsx        "/"            the list
//   order/[id].tsx   "/order/1042" the detail
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: 'Orders' }} />
      <Stack.Screen name="order/[id]" options={{ title: 'Order' }} />
    </Stack>
  );
}

Discussion

  • Be the first to comment on this lesson.