EduFlow

Section 3 · Navigation and State

Tabs, Dynamic Routes and Params

19 min3 min readCode playground

Lesson at a glance

Give Trailhead bottom tabs with a route group, add a hike detail screen with a dynamic [id] route, and read its params safely, remembering that params always arrive as strings.

  1. 1A route group for the tabs
  2. 2Dynamic routes and params
  3. 3Ids in params, data from state

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Trailhead now has more than one place to be: the hike log, a stats page, and settings, plus a detail screen for each hike. The first three are peers that users switch between all the time; the detail screen is something you drill into and come back from. That’s the classic mobile shape: tabs for peers, a stack for drilling in.

A route group for the tabs

Here’s the file structure Trailhead uses:

Text
src/app/
  _layout.tsx          root Stack
  (tabs)/
    _layout.tsx        Tabs navigator
    index.tsx          /          (Log)
    stats.tsx          /stats
    settings.tsx       /settings
  hikes/
    [id].tsx           /hikes/42  (pushed over the tabs)

A folder in parentheses is a route group. It lets the three tab screens share a layout without adding anything to their URLs: (tabs)/stats.tsx is /stats, not /tabs/stats.

The tab layout returns a Tabs navigator, one Tabs.Screen per tab, in the order they should appear:

src/app/(tabs)/_layout.tsx
import Ionicons from '@expo/vector-icons/Ionicons';
import { Tabs } from 'expo-router';

export default function TabLayout() {
  return (
    <Tabs screenOptions={{ tabBarActiveTintColor: '#2f6f4e' }}>
      <Tabs.Screen
        name="index"
        options={{
          title: 'Log',
          tabBarIcon: ({ color, size }) => <Ionicons name="list" color={color} size={size} />,
        }}
      />
      <Tabs.Screen
        name="stats"
        options={{
          title: 'Stats',
          tabBarIcon: ({ color, size }) => <Ionicons name="stats-chart" color={color} size={size} />,
        }}
      />
      <Tabs.Screen
        name="settings"
        options={{
          title: 'Settings',
          tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" color={color} size={size} />,
        }}
      />
    </Tabs>
  );
}

The root layout then puts the whole tab group into the Stack, with the group’s own header hidden (each tab has its own), followed by the detail route:

src/app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="hikes/[id]" options={{ title: 'Hike' }} />
    </Stack>
  );
}

Because hikes/[id] belongs to the root Stack rather than to the tabs, opening a hike slides a full screen over the tab bar, and the back gesture returns to whichever tab you came from. That’s what most iOS and Android apps do, and it gives the detail screen the whole height of the phone.

Two habits keep tabs pleasant. Keep them to between three and five top-level destinations users visit often; a sixth tab usually means some of them belong inside settings. And remember that tabs stay mounted once visited: switch from Log to Stats and back, and the log keeps its scroll position. That’s the behaviour users expect, so don’t reset it. If one tab needs its own drill-down screens that keep the tab bar visible, give that tab a folder with its own _layout.tsx returning a Stack; nesting navigators is just nesting folders.

Dynamic routes and params

A file named with square brackets, [id].tsx, matches any single URL segment and exposes it as a param named id. Read it with useLocalSearchParams:

src/app/hikes/[id].tsx
import { Stack, useLocalSearchParams } from 'expo-router';
import { Text, View } from 'react-native';
import { useHike } from '../../state/hikes';

export default function HikeDetail() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const hike = useHike(id);

  if (!hike) {
    return (
      <View style={{ flex: 1, padding: 24 }}>
        <Text>That hike doesn't exist anymore.</Text>
      </View>
    );
  }

  return (
    <View style={{ flex: 1, padding: 16 }}>
      <Stack.Screen options={{ title: hike.name }} />
      <Text style={{ fontSize: 16 }}>{hike.distanceKm} km</Text>
    </View>
  );
}

To get there, link to the URL. Either build the string or let Expo Router fill in the pattern:

TSX
<Link href={`/hikes/${hike.id}`}>{hike.name}</Link>

<Link href={{ pathname: '/hikes/[id]', params: { id: hike.id } }}>{hike.name}</Link>

Anything in params that isn’t part of the path becomes a query string: params: { id: hike.id, units: 'mi' } produces /hikes/42?units=mi, and the detail screen reads units with the same hook.

Ids in params, data from state

Pass the smallest thing that identifies the data, usually an id, and look the data up on the destination screen. Passing a whole hike object seems convenient, but params are serialized into the URL: the object becomes a string, a deep link can never recreate it, and if the user edits the hike, the detail screen shows the stale copy it was handed. The next lesson builds the shared state that useHike(id) reads from.

useLocalSearchParams returns the params of the screen it’s called in, which is what you want almost always. Its sibling useGlobalSearchParams updates whenever any route changes, even routes in the background, which causes extra renders; reserve it for things like analytics that genuinely care about every navigation.

Next, the state behind those lookups: one source of truth for hikes, shared by every tab and screen.

Key takeaways

  • A folder in parentheses, like (tabs), groups routes under a layout without adding a segment to the URL.
  • Put the tab navigator in (tabs)/_layout.tsx and push detail screens from the root Stack so they cover the tab bar.
  • A file named [id].tsx matches any single segment; read it with useLocalSearchParams().
  • Route params are strings (or arrays of strings), so convert and validate them before use.
  • Pass ids in params, never whole objects, and look the data up on the destination screen.

Further reading

Check your understanding

3 questions

Question 1 of 3

What URL does src/app/(tabs)/stats.tsx serve?

Question 2 of 3

The detail screen does this, and a hike with id 7 never matches. Why?

TSX
const { id } = useLocalSearchParams<{ id: string }>();
const hike = hikes.find((h) => h.id === id); // h.id is a number
Question 3 of 3

A teammate passes a whole hike object as a route param so the detail screen doesn’t need to look it up. What’s the main problem?

Practice

Code playground

Read route params safely

Trailhead’s detail screen at /hikes/[id] can be opened from the app, from a notification, or from an old link with anything in it. Write parseHikeParams(params) that takes the object from useLocalSearchParams() and returns { id, units, photo }:

  • id: a positive whole number, or null if missing or invalid ('abc', '0', '4.5', '-3').
  • units: 'mi' if the param is mi in any case; otherwise 'km'.
  • photo: a whole number of 0 or more (which photo to show first); 0 if missing or invalid.

Any param may also arrive as an array of strings (a repeated query key). Use the first value.

// params is whatever useLocalSearchParams() returned for /hikes/[id]
// Return { id, units, photo } with real types and safe defaults.
function parseHikeParams(params) {
  return { id: params.id, units: params.units, photo: params.photo };
}

console.log(parseHikeParams({ id: '42', units: 'MI', photo: '2' }));
console.log(parseHikeParams({ id: 'abc' }));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • A normal set of params is converted to real types
  • Invalid or missing ids become null
  • Units are case-insensitive and default to km
  • Photo index defaults to 0 for missing or invalid values
  • Array params use their first value