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.
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:
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:
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:
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:
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:
<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.tsxand push detail screens from the root Stack so they cover the tab bar. - A file named
[id].tsxmatches any single segment; read it withuseLocalSearchParams(). - 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.