Основи Expo Router
Основи Expo Router
З чого почати
До цього моменту майже весь Nomad жив на одному «головному» екрані: список поїздок, місця в шапці списку, кнопка «Нова поїздка». Форму створення ми вже винесли в окремий файл create-trip.tsx і відкривали через router.push — це був мінімальний натяк на навігацію, без пояснення, чому файл стає екраном і як влаштована оболонка всього застосунку.
У реальному мобільному продукті екранів десятки: стрічка, деталі, налаштування, пошук, вхід. Користувач очікує:
- нижні вкладки для головних розділів (як у Instagram чи банківському клієнті);
- стопку екранів зі стрічкою «назад» (відкрили деталі — повернулись жестом або кнопкою);
- передбачувану поведінку системної кнопки «назад» на Android.
Expo Router — бібліотека навігації в екосистемі Expo, яка будує ці екрани з файлів і тек у каталозі app/. Ідея близька до file-based routing у Next.js: шлях на диску визначає маршрут (route) у застосунку.
Після статті ви зможете:
- Пояснити, що таке file-based routing і чим
app/відрізняється відsrc/. - Зібрати
_layout.tsxяк оболонку (providers + навігатор). - Налаштувати Tabs (нижні вкладки) і Stack (стопка).
- Переходити між екранами через
LinkіuseRouter(push,back,replace). - Зрозуміти групи
(tabs), index-маршрути, заголовок і кнопку «назад». - Увімкнути typed routes на рівні знайомства.
- Зібрати міні-проєкт «Довідник міст» і перебудувати Nomad на tabs + create-trip.
switch». Це файл у app/, який бібліотека перетворює на маршрут. Layout — постійна оболонка навколо дочірніх маршрутів. Tabs і Stack — два різні способи змінювати, який дочірній екран видно зараз.::react-native-preview не містить Expo Router. Демо навігації перевіряйте в Expo Go / симуляторі. На сторінці — схеми, код і текстові розбори.Код Nomad: github.com/arakviel/nomad.
Навіщо навігація окремою темою
У веб-React ви звикли до «сторінок» і URL. На телефоні немає адресного рядка в тому ж вигляді, але задача та сама: показати інший екран, запам’ятати, звідки прийшли, дати повернутись.
Без бібліотеки навігації новачки часто роблять:
const [screen, setScreen] = useState<'home' | 'form'>('home');
if (screen === 'form') return <Form onBack={() => setScreen('home')} />;
return <Home onOpenForm={() => setScreen('form')} />;
Для двох екранів це працює. Далі з’являються:
- вкладені «стопки» (home → detail → edit);
- вкладки, які не повинні знищувати стан сусідньої вкладки;
- системний жест «назад»;
- deep link «відкрий одразу цей екран» (наступні статті).
React Navigation — низькорівнева бібліотека навігації для React Native (стек, таби, drawer). Expo Router будується поверх React Navigation і додає file-based шар: ви менше пишете конфіг «список екранів у JS-об’єкті», більше — структуру тек.
У курсі основний шлях — Expo Router, бо Nomad уже на Expo, і той самий підхід узгоджується з документацією Expo.
Місток з web React
| На вебі | У Expo Router | Коментар |
|---|---|---|
URL /about | маршрут /about з файлу app/about.tsx | шлях «схожий» на URL |
React Router / Next.js app/ | Expo Router app/ | file-based routing |
<a href> / <Link> | <Link href="…"> з expo-router | декларативний перехід |
router.push (Next) | router.push з useRouter() | імперативний перехід |
| layout.tsx (Next) | _layout.tsx | оболонка сегмента |
| вкладені layout | вкладені _layout у підтеках | Tabs всередині Stack тощо |
| query/params у URL | params (глибше — стаття 13) | на старті — статичні шляхи |
react-router-dom з вебу не ставлять у React Native для цієї задачі. API інший, навігатори — нативні. Expo Router — окремий пакет (expo-router), уже є в залежностях Nomad.Чому «файл = маршрут» зручно
У Next.js звичка: створив app/settings/page.tsx — з’явилась сторінка settings. У Expo Router: створив app/settings.tsx (або app/settings/index.tsx) — з’явився екран, на який можна href="/settings".
Переваги для команди:
- структура проєкту читається як карта застосунку;
- менше «магічного» реєстру екранів у одному великому файлі;
- deep linking (пізніше) природно лягає на ті самі шляхи.
Обмеження, про які варто знати одразу:
- не кожен файл у
app/— екран (наприклад_layout.tsx— оболонка); - імена з
_і()мають спеціальний сенс; - навігація — не лише файли: треба обрати Stack vs Tabs і налаштувати заголовки.
Тека app/ і src/
У структурі курсу (стаття 05):
app/— маршрути й layout навігації (те, що «є екраном» для користувача).src/— логіка, UI-компоненти, тема, features (те, що екрани імпортують).
Екран має лишатися тонким: зібрав дані з хуків, розклав Screen / списки / форми з @/features і @/shared. Бізнес-логіку в app/ не розмножують без потреби — інакше маршрути перетворюються на «божественні» файли на тисячі рядків.
У Nomad після цієї статті:
app/
_layout.tsx ← корінь: providers + Stack
(tabs)/
_layout.tsx ← Tabs
index.tsx ← / (Поїздки)
places.tsx ← /places
about.tsx ← /about
create-trip.tsx ← /create-trip (Stack поверх tabs)
src/
features/trips/…
shared/…
Що таке маршрут (route) і екран (screen)
Маршрут (route) — адреса в системі навігації, наприклад /places або /create-trip. Це логічне ім’я «куди можна перейти».
Екран (screen) — React-компонент, який малюється, коли цей маршрут активний. У file-based підході екран майже завжди = default export з файлу в app/.
Користувач не бачить рядок /places у браузерному сенсі (хоча на web-збірці Expo він може з’явитись у URL). Він бачить вміст і, за наявності, вкладки / заголовок / кнопку назад.
_layout.tsx — оболонка сегмента
Файл _layout.tsx у теці app (або вкладеній теці) не є окремим «екраном у меню». Це layout (розкладка / оболонка): компонент, який обгортає всі маршрути цієї теки.
Типові ролі кореневого layout:
- Підключити провайдери (
ThemeProvider,TripsProvider, пізніше Redux). - Задати кореневий навігатор (
Stackабо інший). - Налаштувати StatusBar, спільний фон.
Дочірні екрани вставляються всередину навігатора. Коли ви переходите з «Поїздки» на «Місця», providers не розмонтовуються — стейт теми й список trips живе далі.
app або підтеку. Ім’я з підкресленням на початку — домовленість Expo Router: «це не route page, а layout». Усередині зазвичай return <Stack /> або <Tabs />, або вкладений layout.<Stack.Screen name="…" options={…} />.Приклад ідеї кореневого layout (схема)
export default function RootLayout() {
return (
<ThemeProvider>
<TripsProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="create-trip" options={{ title: 'Нова поїздка' }} />
</Stack>
</TripsProvider>
</ThemeProvider>
);
}
Провайдери зовні Stack — щоб і вкладки, і create-trip бачили той самий useTrips() і useTheme().
index і імена файлів
| Файл | Типовий шлях |
|---|---|
app/index.tsx | / (стартовий) |
app/places.tsx | /places |
app/create-trip.tsx | /create-trip |
app/(tabs)/index.tsx | / якщо (tabs) — частина кореня без сегмента в URL |
index означає «маршрут самої теки». У групі (tabs) файл index.tsx — домашня вкладка з шляхом /.
Імена файлів краще kebab-case (create-trip.tsx) — вони стають сегментами URL. Уникайте пробілів і кирилиці в іменах файлів маршрутів.
Stack — стопка екранів
Stack (стек, стопка) — навігатор, де екрани лягають один на одного. Новий екран штовхають (push) на вершину; «назад» знімає вершину (back / pop).
Аналогія: стопка аркушів. Верхній видно повністю; під ним лежать попередні. Коли прибираєте верхній — знову бачите той, що був під ним.
Типові сценарії Stack:
- список → деталі;
- головна → форма створення;
- налаштування → підекран «про застосунок».
У React Navigation / Expo Router Stack за замовчуванням показує header (смуга зверху з заголовком і кнопкою назад) — на iOS і Android з різним виглядом, але зі спільною ідеєю.
import { Stack } from 'expo-router'. Дочірні файли теки автоматично стають екранами цього стека (якщо layout повертає <Stack />).name (ім’я файлу/сегмента без розширення) і options: title, headerShown, presentation, headerBackTitle тощо. Не замінює файл екрана — лише налаштовує його в навігаторі.card), модальне вікно (глибше в статті 12) тощо. Для create-trip у Nomad достатньо звичайного card поверх tabs.false (свій UI в контенті); для форми — true, щоб отримати стандартну «Назад».Системна кнопка «назад»
- iOS: свайп від лівого краю + кнопка в header.
- Android: системна кнопка/жест «Back» зазвичай попає стек, якщо навігатор налаштований правильно.
router.back() у коді робить те саме програмно: повернутись на попередній екран у історії. Якщо історії немає (відкрили deep link одразу на форму) — поведінка залежить від конфігурації; для базового сценарію «відкрили з tabs» back повертає на вкладки.
Tabs — нижні вкладки
Tabs (вкладки) — навігатор, де кілька рівноправних розділів доступні з панелі (зазвичай знизу на iOS/Android). Перемикання не будує глибоку стопку «вкладка1 → вкладка2 → вкладка3» у тому ж сенсі, що Stack: це паралельні корені розділів.
Користувач очікує:
- іконку + короткий підпис;
- активний стан іншим кольором;
- збереження позиції скролу / стану при поверненні на вкладку (часто так і є, якщо екран не розмонтували — деталі залежать від налаштувань
lazy/ unmount).
import { Tabs } from 'expo-router'. Layout у теці з вкладками зазвичай return <Tabs>…</Tabs>.name = ім’я файлу (index, places, about). options.title — підпис на панелі. tabBarIcon — функція ({ color, size }) => <Icon … />.colors.primary / textSecondary).AppText variant="title"), як у Nomad.Іконки
У прикладі Nomad — @expo/vector-icons (Ionicons). Пакет ставлять через npx expo install @expo/vector-icons (версія під SDK). Можна обійтись без іконок (лише текст), але продуктовий вигляд зазвичай з іконками.
Групи (name) — дужки в імені теки
Тека (tabs) — група (group). Круглі дужки в імені означають: ця тека організовує файли, але не додає сегмент у шлях URL.
| На диску | Шлях |
|---|---|
app/(tabs)/index.tsx | / |
app/(tabs)/places.tsx | /places |
app/(tabs)/about.tsx | /about |
Без дужок app/tabs/places.tsx був би /tabs/places — зайвий сегмент у шляху.
Навіщо група:
- зібрати вкладки в один layout
Tabs; - відокремити «зона авторизації»
(auth)(стаття 12); - тримати корінь Stack чистим:
(tabs)+ модалки + create-trip.
Link і useRouter
Link — декларативний перехід
Компонент Link з expo-router схожий на якір у вебі: у JSX видно, куди веде елемент.
import { Link } from 'expo-router';
import { Text } from 'react-native';
<Link href="/create-trip">
<Text>Нова поїздка</Text>
</Link>
Часто потрібен свій вигляд кнопки. Prop asChild каже: «не малюй свій wrapper, використай дитину як pressable»:
<Link href="/create-trip" asChild>
<Pressable style={styles.btn}>
<Text>Нова поїздка</Text>
</Pressable>
</Link>
У Nomad на вкладці «Ще» є приклад Link + asChild + Pressable.
useRouter — імперативний перехід
Хук useRouter() повертає об’єкт з методами навігації. Зручно всередині колбеків: після успішного submit, після логіну, у onPress кнопки, коли шлях обчислюється динамічно.
/create-trip — типовий push: з’явиться «назад».push.router.back(), щоб знову побачити tabs і оновлений список.back «в порожнечу» (рідкісні cold-start сценарії).import { useRouter } from 'expo-router';
const router = useRouter();
// кнопка
router.push('/create-trip');
// після збереження форми
router.back();
Коли що обирати
| Ситуація | Засіб |
|---|---|
| Пункт меню, картка «відкрити» | Link (видно в розмітці) |
| Після submit / умови в коді | router.push / back |
| Потрібен повний контроль над Pressable | Link + asChild або лише router у onPress |
href і шляхи
href — рядок (або об’єкт) адреси маршруту. На старті курсу — статичні шляхи:
"/"— домашня вкладка;"/places";"/about";"/create-trip".
Динамічні сегменти ([id]) і params — стаття 13. Тут свідомо не ускладнюємо: спочатку стабільна карта статичних екранів.
app/. Друкарська помилка "/crate-trip" не знайде екран. Typed routes (нижче) допомагають TypeScript спіймати частину таких помилок.Typed routes (коротко)
Typed routes — експеримент / опція Expo Router, коли TypeScript знає допустимі href з вашої теки app/.
У app.json:
"experiments": {
"typedRoutes": true
}
Після цього Link і router.push суворіше перевіряють рядки шляхів. Іноді потрібен перезапуск TS-сервера / npx expo start. Це не замінює тести навігації, але зменшує опечатки.
У Nomad опцію вмикаємо в цій статті на рівні «підтримка з боку проєкту».
Header, safe area і вкладки
Коли headerShown: true на Stack-екрані, системний header займає місце зверху. Ваш Screen із SafeAreaView може додати зайвий відступ, якщо header уже врахував safe area — візуально «подвійний» зазор.
У create-trip Nomad після цієї статті:
- заголовок і «назад» — від Stack;
- контент —
CreateTripFormбез власного рядка «← Назад» (прибрали дубль).
На вкладках header сховано — safe area лишається на Screen.
Антипатерни
1. Усе через useState('screen') у великому App
Швидкий прототип — так; продукт із вкладками, жестом назад і deep link — ні. Ви винаходите те, що Router уже розв’язав.
2. Бізнес-логіка лише в app/
Маршрути товстіють, features не перевикористовуються. Тримайте тонкі екрани + src/features.
3. Плутати групу (tabs) з сегментом URL
Очікувати URL /tabs/places і дивуватись, що працює /places.
4. push замість back після форми
push('/') після створення поїздки може накласти ще один home у стек; «назад» тоді поверне на порожню форму. Після модального/форменного сценарію частіше back() або replace.
5. Забути theme на tab bar
Контент темний, панель вкладок біла «з коробки» — треба tabBarStyle і tint з теми.
6. Ховати create-trip всередині tabs без потреби
Форма як п’ята вкладка — дивний UX. Краще Stack-екран поверх tabs (як у Nomad).
Міні-проєкт: «Довідник міст»
Мета
Окремий застосунок поза Nomad. Шлях від А до Я: команди, структура тек, повний код кожного файлу. Можна відтворити без домислів.
Сюжет:
- Tabs: Список | Про нас.
- Список міст → тап → Stack-екран деталей із системною «назад».
- «Про нас» — текст +
Linkна список.
Кінцева структура
city-guide/
package.json
app.json
app/
_layout.tsx
(tabs)/
_layout.tsx
index.tsx
about.tsx
city/
[id].tsx
src/
data/
cities.ts
Крок 1. Створити проєкт
1. Шаблон з tabs і Router
npx create-expo-app@latest city-guide -t tabs
cd city-guide
У package.json має бути "main": "expo-router/entry" (у шаблоні вже так).
2. Іконки (якщо TypeScript не знаходить пакет)
npx expo install @expo/vector-icons
3. Після підстановки всіх файлів
npx expo start
Крок 2. Дані — src/data/cities.ts
Створіть теки src/data/ і файл цілком:
export type City = {
id: string;
name: string;
region: string;
population: string;
description: string;
};
export const cities: City[] = [
{
id: 'kyiv',
name: 'Київ',
region: 'м. Київ',
population: '≈ 2,9 млн',
description:
'Столиця України. Поділ, Хрещатик, правий і лівий береги Дніпра. Зручна відправна точка для подорожей центром країни.',
},
{
id: 'lviv',
name: 'Львів',
region: 'Львівська область',
population: '≈ 720 тис.',
description:
'Місто на заході з площею Ринок, кавою й щільною історичною забудовою. Добре для вікенду пішки.',
},
{
id: 'odesa',
name: 'Одеса',
region: 'Одеська область',
population: '≈ 1 млн',
description:
'Порт на Чорному морі. Дерибасівська, набережна, особливий гумор і кухня. Літній ритм міста інший, ніж узимку.',
},
{
id: 'kharkiv',
name: 'Харків',
region: 'Харківська область',
population: '≈ 1,4 млн',
description:
'Велике місто на сході. Університети, парки, широкі проспекти. Важливий культурний і промисловий осередок.',
},
{
id: 'dnipro',
name: 'Дніпро',
region: 'Дніпропетровська область',
population: '≈ 970 тис.',
description:
'Місто на річці Дніпро з довгою набережною й мостами. Зручний вузол між сходом і центром.',
},
];
export function getCityById(id: string): City | undefined {
return cities.find((c) => c.id === id);
}
Крок 3. Корінь Stack — app/_layout.tsx
Повністю замініть вміст:
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
export default function RootLayout() {
return (
<>
<StatusBar style="dark" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#F8FAFC' },
headerTintColor: '#2563EB',
headerTitleStyle: { fontWeight: '600', color: '#0F172A' },
contentStyle: { backgroundColor: '#F8FAFC' },
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="city/[id]"
options={{
title: 'Про місто',
headerBackTitle: 'Назад',
}}
/>
</Stack>
</>
);
}
Крок 4. Tabs — app/(tabs)/_layout.tsx
Повністю замініть вміст. Лише дві вкладки: index і about.
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#2563EB',
tabBarInactiveTintColor: '#64748B',
tabBarStyle: {
backgroundColor: '#F8FAFC',
borderTopColor: '#E2E8F0',
},
tabBarLabelStyle: { fontSize: 12, fontWeight: '600' },
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Список',
tabBarIcon: ({ color, size }) => (
<Ionicons name="list-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="about"
options={{
title: 'Про нас',
tabBarIcon: ({ color, size }) => (
<Ionicons
name="information-circle-outline"
size={size}
color={color}
/>
),
}}
/>
</Tabs>
);
}
Крок 5. Список — app/(tabs)/index.tsx
Повністю замініть вміст:
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { Link } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { cities } from '../../src/data/cities';
export default function CitiesListScreen() {
return (
<SafeAreaView style={styles.safe} edges={['top']}>
<View style={styles.header}>
<Text style={styles.title}>Довідник міст</Text>
<Text style={styles.muted}>{cities.length} міст · тапніть картку</Text>
</View>
<FlatList
data={cities}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={{ height: 10 }} />}
renderItem={({ item }) => (
<Link href={`/city/${item.id}`} asChild>
<Pressable style={styles.card}>
<Text style={styles.cardTitle}>{item.name}</Text>
<Text style={styles.cardMeta}>{item.region}</Text>
<Text style={styles.linkHint}>Деталі →</Text>
</Pressable>
</Link>
)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#F8FAFC' },
header: {
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#E2E8F0',
},
title: { fontSize: 24, fontWeight: '800', color: '#0F172A' },
muted: { marginTop: 4, color: '#64748B', fontSize: 13 },
list: { padding: 16, paddingBottom: 32 },
card: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
borderWidth: 1,
borderColor: '#E2E8F0',
padding: 14,
},
cardTitle: { fontSize: 18, fontWeight: '700', color: '#0F172A' },
cardMeta: { marginTop: 4, color: '#64748B', fontSize: 13 },
linkHint: {
marginTop: 10,
color: '#2563EB',
fontWeight: '700',
fontSize: 13,
},
});
Крок 6. «Про нас» — app/(tabs)/about.tsx
Повністю замініть (або створіть, якщо не було):
import { ScrollView, StyleSheet, Text } from 'react-native';
import { Link } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function AboutScreen() {
return (
<SafeAreaView style={styles.safe} edges={['top']}>
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.title}>Про довідник</Text>
<Text style={styles.p}>
Навчальний міні-проєкт курсу React Native: дві вкладки (Tabs) і екран
деталей у Stack. Дані — локальний масив міст, без мережі.
</Text>
<Text style={styles.p}>
Список відкриває маршрут /city/[id] через компонент Link. Кнопка
«назад» на екрані деталей — системна (Stack header).
</Text>
<Link href="/" style={styles.link}>
← До списку міст
</Link>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#F8FAFC' },
body: { padding: 16, gap: 12, paddingBottom: 40 },
title: { fontSize: 24, fontWeight: '800', color: '#0F172A' },
p: { fontSize: 16, lineHeight: 24, color: '#334155' },
link: {
marginTop: 8,
color: '#2563EB',
fontWeight: '700',
fontSize: 16,
},
});
Крок 7. Деталі — app/city/[id].tsx
Створіть теку app/city/ і файл [id].tsx цілком:
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { Stack, useLocalSearchParams } from 'expo-router';
import { getCityById } from '../../src/data/cities';
export default function CityDetailsScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const cityId = Array.isArray(id) ? id[0] : id;
const city = cityId ? getCityById(cityId) : undefined;
if (!city) {
return (
<View style={styles.center}>
<Stack.Screen options={{ title: 'Не знайдено' }} />
<Text style={styles.title}>Місто не знайдено</Text>
<Text style={styles.muted}>Немає запису з id: {String(cityId)}</Text>
</View>
);
}
return (
<ScrollView contentContainerStyle={styles.body}>
<Stack.Screen options={{ title: city.name }} />
<Text style={styles.title}>{city.name}</Text>
<Text style={styles.meta}>{city.region}</Text>
<Text style={styles.meta}>Населення: {city.population}</Text>
<Text style={styles.p}>{city.description}</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
body: { padding: 16, gap: 8, paddingBottom: 40 },
center: {
flex: 1,
padding: 24,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F8FAFC',
},
title: { fontSize: 26, fontWeight: '800', color: '#0F172A' },
meta: { fontSize: 14, color: '#64748B', fontWeight: '600' },
muted: { marginTop: 8, color: '#64748B' },
p: { marginTop: 12, fontSize: 16, lineHeight: 24, color: '#334155' },
});
useLocalSearchParams читає id з шляху /city/kyiv. Глибше про params і deep links — у статті 13; тут мінімум, щоб Stack мав сенс.
Крок 8. Прибрати зайве з шаблону tabs
- Видаліть
app/(tabs)/explore.tsx(або інший другий tab шаблону), якщо він є. - У
(tabs)/_layout.tsxмають лишитись лишеindexіabout(як у нашому файлі). - Видаліть невикористані компоненти/ассети шаблону за бажанням.
Опційно в app.json всередині expo:
"experiments": {
"typedRoutes": true
}
Крок 9. Перевірка
1. Старт
npx expo start → Expo Go / симулятор.
2. Вкладки
Внизу «Список» і «Про нас»; перемикання змінює екран.
3. Деталі
Тап «Львів» → опис; header зі стрілкою / «Назад»; жест назад на iOS/Android.
4. Про нас
Текст і Link «До списку міст».
5. Невідомий id
Якщо відкрити /city/xxx (через Link у коді тимчасово) — «Місто не знайдено».
Критерій «готово»
-
create-expo-app -t tabs - Усі файли кроків 2–7 з повним кодом (не фрагменти «допишіть»)
- Дві вкладки українською
- Список → деталі → назад
- Немає
useState('screen')замість Router - Дані в
src/data/cities.ts
Карта ідей курсу → файли
| Ідея | Де |
|---|---|
| File-based routing | app/** |
| Tabs | (tabs)/_layout.tsx |
| Stack + back | кореневий _layout + city/[id] |
| Link | список, about |
Група (tabs) | не в URL |
| Динамічний сегмент | [id] + useLocalSearchParams |
Nomad: file-based navigation structure
Навіщо користувачу
Застосунок перестає бути «одним довгим екраном». З’являються розділи:
- Поїздки — стрічка, тема, sticky «Нова поїздка» (як раніше);
- Місця — усі mock-місця сіткою;
- Ще — опис, тема, приклад
Linkна форму.
Форма створення — окремий stack-екран із системним заголовком і «назад», без дубльованого UI «← Назад» у контенті.
Нитка проєкту
Уже є (і зберігаємо):
- ThemeProvider, чіпи теми (на вкладці Поїздки; також на «Ще»);
- FlashList поїздок, pull-to-refresh, TripCard, місця в header списку;
- CreateTripForm (RHF+Zod, усі контроли);
- TripsProvider, addTrip, картки з мітками.
Додаємо / змінюємо:
| Зміна | Навіщо |
|---|---|
app/(tabs)/ + Tabs layout | три головні розділи |
перенесення home → (tabs)/index.tsx | маршрут / у вкладках |
places.tsx, about.tsx | нові вкладки |
кореневий Stack з options для create-trip | header + back |
typedRoutes: true | підтримка типізації шляхів |
@expo/vector-icons | іконки tab bar |
спрощення create-trip.tsx | без свого back-рядка |
Встановлення іконок (якщо збираєте вручну)
cd /path/to/nomad
npx expo install @expo/vector-icons
# за peer-конфліктів npm: npm install @expo/vector-icons --legacy-peer-deps
Повний знімок проєкту
Перевірка
- Внизу три вкладки: Поїздки, Місця, Ще (іконки + підписи).
- На «Поїздки» — стрічка, тема, місця в header, sticky «Нова поїздка».
- «Нова поїздка» → екран із системним заголовком і назад; форма як раніше.
- Успішне створення →
backна вкладки, картка зверху списку. - «Місця» — сітка всіх mock-місць.
- «Ще» — текст + тема +
Linkна форму. - Перемикання вкладок не губить тему (провайдер на корені).
Коміт
cd /path/to/nomad
git add -A
git commit -m "$(cat <<'EOF'
feat: file-based navigation structure
Material: content/15.react-native/11.expo-router-basics.md
EOF
)"
git push
Прев’ю (обмежене)
У iframe Router немає. Нижче — макет трьох «вкладок» на state (ідея UX). Повна поведінка — у Expo Go.
Loading…
react-native-web · not a real device
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View, useColorScheme } from 'react-native';
const light = { bg: '#F8FAFC', text: '#0f172a', muted: '#64748b', primary: '#2563eb', border: '#e2e8f0', surface: '#fff', on: '#fff' };
const dark = { bg: '#000', text: '#f5f5f7', muted: '#a1a1aa', primary: '#3b82f6', border: '#3a3a3c', surface: '#1c1c1e', on: '#fff' };
export default function App() {
const sys = useColorScheme() === 'dark';
const c = sys ? dark : light;
const [tab, setTab] = useState<'trips' | 'places' | 'about'>('trips');
return (
<View style={[styles.root, { backgroundColor: c.bg }]}>
<View style={{ flex: 1, padding: 16 }}>
{tab === 'trips' && (
<>
<Text style={[styles.h, { color: c.text }]}>Поїздки</Text>
<Text style={{ color: c.muted, marginTop: 8 }}>Стрічка · FlashList · CTA внизу (у репо)</Text>
<View style={[styles.card, { backgroundColor: c.surface, borderColor: c.border }]}>
<Text style={{ color: c.text, fontWeight: '700' }}>Карпати на вихідні</Text>
<Text style={{ color: c.muted, marginTop: 4 }}>12–14 бер. 2026</Text>
</View>
</>
)}
{tab === 'places' && (
<>
<Text style={[styles.h, { color: c.text }]}>Місця</Text>
<Text style={{ color: c.muted, marginTop: 8 }}>Окрема вкладка замість лише горизонтальної стрічки</Text>
</>
)}
{tab === 'about' && (
<>
<Text style={[styles.h, { color: c.text }]}>Ще</Text>
<Text style={{ color: c.muted, marginTop: 8 }}>Тема, Link, про застосунок — у репо на Expo Router</Text>
</>
)}
</View>
<View style={[styles.bar, { borderColor: c.border, backgroundColor: c.bg }]}>
{([
['trips', 'Поїздки'],
['places', 'Місця'],
['about', 'Ще'],
] as const).map(([id, label]) => {
const on = tab === id;
return (
<Pressable key={id} onPress={() => setTab(id)} style={styles.tab}>
<Text style={{ color: on ? c.primary : c.muted, fontWeight: '700', fontSize: 12 }}>{label}</Text>
</Pressable>
);
})}
</View>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1 },
h: { fontSize: 24, fontWeight: '800' },
card: { marginTop: 16, padding: 14, borderRadius: 12, borderWidth: 1 },
bar: { flexDirection: 'row', borderTopWidth: 1, paddingVertical: 10 },
tab: { flex: 1, alignItems: 'center', paddingVertical: 8 },
});
Практичні завдання
Базовий рівень
- Своїми словами: чим Stack відрізняється від Tabs для користувача.
- Пояснити, чому
(tabs)не потрапляє в URL. - Назвати різницю між
Linkіrouter.push.
Середній рівень
- Міні-проєкт «Довідник міст».
- У Nomad додати четверту вкладку «Чернетки»-заглушку (екран з текстом) і прибрати її з tab bar через
href: null(див. docs) — або просто три вкладки без заглушки. - Замінити один
router.pushнаLink+asChildна кнопці «Нова поїздка».
Професійний рівень
- Увімкнути typed routes і навмисно зламати
href— показати помилку TS. - Порівняти
pushvsreplaceпісля create-trip (що з кнопкою «назад»). - Нотатка: що станеться, якщо
TripsProviderпокласти всередину лише(tabs)layout, а не root — і чому форма тоді «не бачить» trips.
Часті запитання
index, групу (tabs), перезапуск Metro. Опечатка в href — часта причина.expo-router.Що далі
Базова карта маршрутів готова: tabs + stack. Далі — вкладені навігатори та модалки: Stack усередині вкладки, presentation: 'modal', групи auth, guard на back. Потім — params і deep links.
Expo Router · File-based routing · Tabs · Typed routes · Nomad
Форми, ввід і валідація
Усі основні контроли форми в React Native — TextInput, Pressable, Button, Switch, Checkbox, Slider, Picker, date picker; клавіатура; React Hook Form і Zod; реєстрація на подію та створення поїздки в Nomad
Вкладені навігатори та модалки
Stack усередині Tabs, presentation modal, групи auth, guard на back — нотатки з модалкою і деталі поїздки в Nomad