Основи Expo Router
Основи Expo Router
Вступ: Навігація в мобільних додатках та файловий роутинг (File-Based Routing)
У процесі розвитку мобільного застосунку навантаження на інтерфейсний шар неминуче зростає. На початкових етапах розробки проєкт Nomad функціонував як монолітний одноекранний інтерфейс: спискове відображення поїздок, блок збережених локацій у заголовку списку та плаваюча кнопка переходу до форми створення. Попередній виніс форми створення поїздки в окремий модуль create-trip.tsx з активацією через виклик методу router.push продемонстрував базову можливість зміни стану екрана, проте залишив поза увагою принципи функціонування навігаційного рушія, життєвий цикл мобільних контейнерів та ієрархію глобальної оболонки застосунку.
Реальний мобільний продукт ніколи не обмежується одним екраном. У ньому завжди є десятки різних екранів: новинні стрічки, детальні картки, панелі налаштувань, пошук, авторизація та модальні діалоги. Взаємодія з користувачем на мобільних пристроях ґрунтується на зрозумілих та звичних патернах:
- Нижні навігаційні вкладки (Bottom Tabs): основний орієнтир застосунку для швидкого доступу до головних розділів (як у банківських додатках чи соцмережах).
- Ієрархічна стопка екранів (Navigation Stack): організовує переходи углиб контенту за принципом «від списку до деталей» зі збереженням історії переходів та можливістю повернення назад свайпом чи кнопкою «Назад».
- Коректна обробка системних жестів та кнопок: підтримка апаратної кнопки повернення на Android та жестової смуги навігації на iOS.
Expo Router — це спеціалізований фреймворк маршрутизації та навігації для екосистеми Expo та React Native, який використовує файловий роутинг (File-Based Routing). Головна ідея запозичена з сучасних веб-фреймворків (зокрема Next.js App Router): структура файлів і папок усередині каталогу app/ автоматично перетворюється на навігаційні маршрути (Routes) та контейнери застосунку.
Навчальні цілі розділу:
- Опанувати концепцію File-Based Routing у нативному мобільному середовищі та засвоїти правила архітектурного розмежування між каталогом маршрутів
app/та шаром бізнес-логіки й компонентівsrc/. - Дослідити архітектуру макетів
_layout.tsxяк постійної контекстної оболонки, що інкапсулює спільні провайдери станів (State Providers) та нативні навігатори. - Сконфігурувати базові навігаційні контейнери: паралельні вкладки Tabs та ієрархічну стопку Stack.
- Засвоїти механізми переходами між маршрутами за допомогою декларативного компонента
Link(з підтримкою властивостіasChild) та імперативного хукаuseRouter(методиpush,back,replace,canGoBack). - Зрозуміти семантику ізольованих груп маршрутів у круглих дужках
(group), призначення файлівindex.tsx, а також правила конфігурації нативного заголовка (Header) та безпечних зон (Safe Area Insets). - Активувати та інтегрувати механізм статичної типізації маршрутів Typed Routes для компіляційної верифікації шляхів навігації.
- Розробити автономний навчальний міні-проєкт «Довідник міст» та здійснити рефакторинг проєкту Nomad на повноцінну навігаційну структуру з вкладками та модальною стопкою.
switch або тернарному операторі. Це експортований за замовчуванням React-компонент у файлі всередині каталогу app/, який маршрутизатор перетворює на повноцінний вузол графа навігації. Файл _layout.tsx формує спільний каркас навколо дочірніх сегментів, тоді як навігатори Stack і Tabs визначають структуру та анімацію переходу між цими екранами.::react-native-preview) виконуються в ізольованому пісочничному оточенні без підключення нативного рушія Expo Router. Тому повноцінну перевірку переходів, стеків, жестів та збереження станів слід виконувати безпосередньо в середовищі Expo Go або нативному симуляторі/емуляторі. У тексті статті наведено вичерпні архітектурні схеми, код та аналітичні розбори.Вихідний код репозиторію проєкту Nomad: github.com/arakviel/nomad.
Архітектурна необхідність виділеної системи навігації
У традиційній веб-розробці зміна представлення тісно пов'язана з адресним рядком браузера (URL), об'єктом window.history та подіями popstate. На мобільних пристроях адресний рядок відсутній, проте задачі залишаються тими самими: передбачувано відобразити потрібний екран, зберегти історію переходів та надати користувачеві можливість повернутися назад без втрати стану даних.
Початківці часто намагаються реалізувати перемикання екранів за допомогою локального реактивного стану:
const [currentScreen, setCurrentScreen] = useState<'home' | 'form'>('home');
if (currentScreen === 'form') {
return <TripFormScreen onBack={() => setCurrentScreen('home')} />;
}
return <HomeScreen onOpenForm={() => setCurrentScreen('form')} />;
Такий спрощений підхід, хоча й працює для примітивних демонстрацій із двох екранів, виявляється абсолютно нежиттєздатним у масштабованих архітектурах через низку критичних проблем:
- Руйнування життєвого циклу компонентів і втрата стану: Умовний рендеринг через тернарний оператор або
ifповністю розмонтовує попередній компонент (HomeScreen). При поверненні назад дерево монтується заново, що призводить до втрати положення скролу у списках, скидання тимчасових фільтрів і повторного виконання мережевих запитів. - Відсутність підтримки складних навігаційних ієрархій: Спроба вручну синхронізувати вкладені стопки переходів (наприклад,
Home -> Details -> Edit -> Confirm), незалежні паралельні вкладки та модальні діалоги призводить до експоненційного ускладнення коду (State Explosion) та появи важковловимих багів. - Повна втрата нативних анімацій та системних жестів: Ручне перемикання екранів не використовує апаратні транзиції операційної системи. Користувач втрачає плавний інерційний свайп повернення на iOS (Interactive Pop Gesture) та обробку системної кнопки «Назад» на Android.
- Неможливість обробки глибоких посилань (Deep Linking): Застосунок не здатний коректно запуститися за зовнішнім URL-посиланням (наприклад,
nomad://trips/42з push-повідомлення чи браузера), оскільки відсутній шар зіставлення рядкової адреси з вузлом дерева екранів.
РУЧНЕ КЕРУВАННЯ СТАНОМ EXPO ROUTER (FILE-BASED)
+------------------------------------+ +-----------------------------------------+
| useState('home' | 'form') | | app/ |
| - Повна втрата стану при unmount | vs | ├── (tabs)/index.tsx (Маршрут "/") |
| - Відсутні нативні жести/свайпи | | └── create-trip.tsx (Маршрут "/...") |
| - Неможливий Deep Linking | +-----------------------------------------+
| - Складний спагеті-код переходів | | • Нативна оптимізація пам'яті (Screens) |
+------------------------------------+ | • Збереження стану та позиції скролу |
| • Нативна підтримка Deep Linking та URL |
+-----------------------------------------+
Рівні абстракції: React Navigation та Expo Router
Для вирішення цих викликів у спільноті React Native було створено бібліотеку React Navigation — де-факто стандарт навігації, який забезпечує низькорівневі нативні примітиви (стековий навігатор на базі react-native-screens, нижні панелі вкладок, бокові панелі Drawer).
Expo Router побудований як надбудова поверх React Navigation. Він усуває необхідність громіздкої імперативної конфігурації екранів через велетенські JS-об'єкти чи вкладені JSX-структури (<NavigationContainer>, <Stack.Navigator>, <Stack.Screen>), замінюючи її декларативним зіставленням файлової системи. Фреймворк автоматично генерує граф переходів, серіалізує стан навігації в URL-подібну структуру та надає повну підтримку універсального рендерингу (iOS, Android, Web).
У рамках нашого курсу основним стандартом обрано саме Expo Router, оскільки проєкт Nomad побудований на сучасному стеку Expo, а файлова маршрутизація є офіційним вектором розвитку всієї екосистеми React Native.
Концептуальний місток: Веб-орієнтований React проти Expo Router
Для спеціалістів, які мають досвід роботи з веб-версією React (зокрема з React Router DOM або Next.js), концепції Expo Router будуть інтуїтивно близькими. Проте через специфіку функціонування мобільних операційних систем існують важливі архітектурні відмінності:
| Концепція у Web (Next.js / React Router) | Відповідник у Expo Router | Інженерний коментар та семантика |
|---|---|---|
Браузерний URL /about | Маршрут /about (файл app/about.tsx) | Шлях у файловій системі визначає точку входу в застосунок. |
Каталог сторінок app/ у Next.js | Каталог app/ у корені проєкту | Концепція File-Based Routing: файли автоматично стають маршрутами. |
Тег <a href="..."> або <Link> з Next.js | Компонент <Link href="..."> з expo-router | Декларативний механізм навігації з підтримкою доступності (Accessibility). |
Метод router.push('/path') | Метод router.push('/path') з useRouter() | Імперативний перехід, що виконується в обробниках подій та колбеках. |
Файл макета layout.tsx (Next.js) | Файл макета _layout.tsx | Компонентна оболонка сегмента; зберігає стан під час перемикання дітей. |
| Вкладені макети (Nested Layouts) | Вкладені _layout.tsx у піддиректоріях | Дозволяє комбінувати різні типи навігаторів (наприклад, Tabs усередині Stack). |
| Query/Path-параметри URL | Хуки useLocalSearchParams та useGlobalSearchParams | Передача динамічних аргументів між екранами. |
react-router-dom, призначений для веб-браузерів, категорично не використовується в розробці під React Native. Веб-роутери маніпулюють DOM-деревом (window.history, document.location), які відсутні в нативному середовищі. Навігація в мобільних додатках здійснюється через пакет expo-router, який транслює дії в нативні команди мобільної ОС через бібліотеку react-native-screens.Переваги підходу «Файл як маршрут» (File-as-a-Route)
Організація структури застосунку на основі файлового роутингу надає суттєві переваги для масштабування кодової бази та командної взаємодії:
- Самодокументована архітектура: Дерево каталогів усередині
app/є наочною ментальною картою застосунку. Будь-який розробник одразу розуміє, які екрани існують у продукті та як вони згруповані. - Відсутність централізованого монолітного реєстру: Зникає потреба підтримувати велетенські файли конфігурації навігації, де вручну реєструються сотні екранів та зв'язків між ними (що в командах часто ставало джерелом конфліктів злиття у системі контролю версій Git).
- Природна уніфікація Deep Linking: Оскільки кожен екран автоматично прив'язаний до статичного або динамічного рядкового шляху, конфігурація вхідних посилань (наприклад,
myapp://trip/123) починає працювати автоматично без ручного написання складних регулярних виразів для парсингу URL.
Водночас необхідно враховувати базові правила файлового роутера:
- Не кожен файл у директорії
app/є кінцевим екраном: файли з префіксом підкреслення (зокрема_layout.tsx) виконують службову роль макетів-оболонок. - Назви директорій у круглих дужках
(group)є логічними групами й не створюють сегментів у фінальному URL. - Розробник зобов'язаний явно вказувати тип навігаційного контейнера для кожного рівня (Stack чи Tabs) та конфігурувати заголовки й безпечні зони.
Фізична організація проєкту: Сегрегація директорій app/ та src/
У структурі архітектури нашого навчального курсу встановлено суворе розділення відповідальностей між двома ключовими каталогами:
АРХІТЕКТУРНА СЕГРЕГАЦІЯ
+---------------------------------------+ +---------------------------------------+
| app/ | | src/ |
| (Навігаційний рівень) | | (Рівень бізнес-логіки) |
+---------------------------------------+ +---------------------------------------+
| • Маршрутизація та точки входу | | • Доменні сутності (Features) |
| • Конфігурація нативних макетів | | • UI-компоненти та дизайн-система |
| • _layout.tsx (Stack, Tabs) | | • Стан, хуки, схеми валідації |
| • «Тонкі контролери» (Thin Screens) | | • Чисті сервіси та утиліти |
+---------------------------------------+ +---------------------------------------+
\ /
\--- Імпортує необхідні сутності ---/
- Директорія
app/— шар навігації та макетів: Містить виключно файли маршрутів і файли макетів (_layout.tsx). Компоненти, розташовані тут, повинні залишатися «тонкими екранами» (Thin Screens). Їхнє єдине завдання — отримати навігаційні параметри (наприклад, через хукuseLocalSearchParams), підключити відповідні доменні хуки або сервіси та змонтувати готові складені компоненти з шаруsrc/. - Директорія
src/— шар бізнес-логіки, доменних фіч та дизайн-системи: Містить ізольовані модулі функціоналу (features/trips), спільні UI-компоненти (shared/ui), конфігурацію теми (shared/theme) та клієнти взаємодії з даними. Цей шар нічого не знає про файлову структуруapp/, що забезпечує високий рівень повторного використання коду та спрощує модульне тестування.
Якщо розміщувати важку бізнес-логіку, стан форм чи складні алгоритми безпосередньо у файлах app/, екрани швидко перетворюються на важкі антипатерні модулі (God Objects), які неможливо повторно перевикористати або протестувати в ізоляції.
Структура каталогу проєкту Nomad після впровадження повноцінної навігації:
app/
_layout.tsx ← Кореневий макет: глобальні провайдери + головний Stack
(tabs)/
_layout.tsx ← Навігатор нижніх вкладок (Tabs Layout)
index.tsx ← Маршрут "/" (Головна вкладка «Поїздки»)
places.tsx ← Маршрут "/places" (Вкладка «Місця»)
about.tsx ← Маршрут "/about" (Вкладка «Ще»)
create-trip.tsx ← Маршрут "/create-trip" (Екран у Stack поверх вкладок)
src/
features/trips/ ← Доменна бізнес-логіка поїздок (моделі, форми, картки)
shared/ ← Спільні компоненти UI, токени теми та утиліти
Семантичні одиниці: Маршрут (Route) та Екран (Screen)
Для побудови коректної ментальної моделі необхідно чітко розрізняти дві базові абстракції:
- Маршрут (Route):
Це абстрактна логічна адреса (ідентифікатор) у навіційній системі застосунку, наприклад
/,/placesабо/create-trip. Маршрут визначає, куди спрямовується навігаційний потік і як цей стан серіалізується для механізмів Deep Linking або історії переходів. - Екран (Screen):
Це конкретний візуальний React-компонент, який монтується та відмальовується у вікні перегляду, коли відповідний маршрут стає активним. У системі Expo Router екран завжди декларується як експорт за замовчуванням (
export default function Screen()) із відповідного файлу вapp/.
Хоча на мобільних платформах користувач не взаємодіє з текстовим рядком URL напряму (на відміну від веб-версії, де URL синхронізується з адресним рядком браузера), уся навігаційна підсистема функціонує за тими самими принципами просторової адресації.
_layout.tsx — Декларативна оболонка та межа сегмента
Файл з конвенційною назвою _layout.tsx є ключовим будівельним блоком Expo Router. Він розміщується в корені директорії app/ або в будь-якій її піддиректорії.
На відміну від звичайних файлів, _layout.tsx ніколи не стає самостійною кінцевою сторінкою в навігації. Це постійний компонент-обгортка (Layout Wrapper), який обрамляє всі маршрути поточної директорії та її підкаталогів.
АРХІТЕКТУРА МАКЕТІВ
+-------------------------------------------------------------------------+
| _layout.tsx (Кореневий макет: ThemeProvider + TripsProvider + Stack) |
| |
| +-------------------------------------------------------------------+ |
| | (tabs)/_layout.tsx (Вкладений макет: Tabs Navigator) | |
| | | |
| | [index.tsx (Поїздки)] [places.tsx (Місця)] [about.tsx (Ще)] | |
| +-------------------------------------------------------------------+ |
| |
| +-------------------------------------------------------------------+ |
| | create-trip.tsx (Stack Screen поверх панелі вкладок) | |
| +-------------------------------------------------------------------+ |
+-------------------------------------------------------------------------+
Основні інженерні обов'язки кореневого макета (app/_layout.tsx):
- Ініціалізація глобальних провайдерів стану: Підключення контекстів теми (
ThemeProvider), глобальних доменних сховищ (TripsProvider), клієнтів кешування даних чи авторизації. - Конфігурація кореневого навігаційного контейнера: Декларація типу головного навігатора застосунку (найчастіше це кореневий
Stack, який керує переходами між основними розділами та модальними вікнами). - Управління системними елементами ОС: Конфігурація нативного статус-бара (
StatusBar), глобальних фонових кольорів вікна та обробка нативних екранів завантаження (Splash Screen).
Життєвий цикл макета та збереження стану
Найважливіша перевага _layout.tsx полягає в його персистентності. Коли користувач здійснює навігацію між дочірніми маршрутами (наприклад, переходить із головного екрана / на форму /create-trip), сам компонент _layout.tsx не розмонтовується.
Це гарантує, що:
- Усі вкладені React-провайдери зберігають свій актуальний стан у пам'яті (тема оформлення, закешовані списки та дані авторизації не ініціалізуються повторно).
- Не відбувається зайвих повторних рендерів глобальних підсистем застосунку.
<Stack /> або <Tabs />), передаючи спільні опції всім дочірнім екранам.import { Slot } from 'expo-router'), що слугує геометричним місцем рендерингу активного дочірнього маршруту сегмента, якщо розробник будує кастомний макет без використання вбудованих нативних контейнерів Stack або Tabs.headerShown), палітру кольорів навігаційної смуги, анімаційні переходи та нативні стилі тексту.Приклад конфігурації кореневого макета
У наведеному лістингу продемонстровано архітектурний патерн правильного розташування глобальних провайдерів навколо стекового навігатора:
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { TripsProvider } from '@/features/trips';
import { ThemeProvider, useTheme } from '@/shared/theme';
function RootNavigator() {
const { colors, scheme } = useTheme();
return (
<>
<StatusBar style={scheme === 'dark' ? 'light' : 'dark'} />
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.background },
headerTintColor: colors.primary,
headerTitleStyle: { color: colors.text, fontWeight: '600' },
contentStyle: { backgroundColor: colors.background },
}}
>
{/* Група вкладок: заголовок кореневого стека приховується */}
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
{/* Окремий екран форми: заголовок стека активний з кнопкою повернення */}
<Stack.Screen
name="create-trip"
options={{
title: 'Нова поїздка',
headerShown: true,
presentation: 'card',
headerBackTitle: 'Назад',
}}
/>
</Stack>
</>
);
}
export default function RootLayout() {
return (
<ThemeProvider>
<TripsProvider>
<RootNavigator />
</TripsProvider>
</ThemeProvider>
);
}
Зверніть увагу: провайдери ThemeProvider та TripsProvider обгортають компонент RootNavigator. Завдяки цьому дочірні екрани обох гілок (як вкладки (tabs), так і окрема форма create-trip) мають однаковий доступ до контекстів через призначені хуки useTheme() та useTrips().
Конвенції файлової структури та маршрутизація index
Expo Router використовує строгі конвенції зіставлення шляхів файлів із маршрутами застосунку:
| Шлях до файлу на диску | Згенерований маршрут у системі | Інженерне призначення |
|---|---|---|
app/index.tsx | / | Головний стартовий екран кореневого рівня. |
app/places.tsx | /places | Окремий статичний екран першого рівня. |
app/create-trip.tsx | /create-trip | Окремий екран створення поїздки. |
app/(tabs)/index.tsx | / | Головний екран групи (tabs), що відповідає кореневому URL /. |
app/(tabs)/places.tsx | /places | Екран списку місць усередині групи вкладок. |
app/city/[id].tsx | /city/:id (напр. /city/kyiv) | Динамічний параметризований маршрут. |
Семантична роль файлів index.tsx
Файл з назвою index.tsx завжди позначає кореневий (дефолтний) маршрут того каталогу, в якому він розташований. Якщо такий файл знаходиться безпосередньо в app/index.tsx або всередині групи app/(tabs)/index.tsx, він стає точкою входу для базового шляху /.
Рекомендації щодо найменування файлів:
- Для назв файлів слід використовувати виключно стиль kebab-case (наприклад,
create-trip.tsx,user-profile.tsx), оскільки імена файлів безпосередньо формують сегменти URL. - Категорично заборонено використовувати пробіли, службові символи та символи нелатинських абеток (кирилицю) в іменах файлів маршрутів.
Навігатор Stack: Модель стекової стопки екранів (LIFO)
Stack-навігатор (Navigation Stack) моделює поведінку просторової стопки карток за класичним принципом інформатики LIFO (Last In, First Out — останнім прийшов, першим пішов).
Коли користувач ініціює перехід до нового екрана, новий інтерфейсний шар «наштовхується» (push) на вершину стека, повністю або частково перекриваючи попередній. При поверненні назад верхній екран «знімається» зі стека (pop), відновлюючи видимість попереднього представлення разом із його незмінним станом пам'яті.
ОПЕРАЦІЯ PUSH (router.push) ОПЕРАЦІЯ POP (router.back)
+------------------------------------+ +------------------------------------+
| [Вершина] create-trip.tsx | --> | [Знято зі стека і знищено] |
+------------------------------------+ +------------------------------------+
| [Основа] (tabs) / index.tsx | <-- | [Відновлено стан] (tabs)/index.tsx|
+------------------------------------+ +------------------------------------+
Типові сценарії застосування Stack:
- Ієрархічний перехід «Список сутностей → Детальна інформація про сутність».
- Відкриття допоміжних форм введення даних або майстрів налаштування (Wizards).
- Заглиблення в дерево налаштувань («Налаштування → Безпека → Зміна пароля»).
На рівні операційної системи Stack-навігатор Expo Router транслюється у високопродуктивні нативні контролери: UINavigationController в iOS та відповідні фрагментні транзакції FragmentTransaction в AndroidX. Це гарантує нативну швидкість анімацій зі швидкістю 60/120 кадрів на секунду та мінімальне навантаження на головний потік JavaScript.
import { Stack } from 'expo-router'). Дочірні файли поточної директорії автоматично реєструються як доступні екрани цього стека.name повинен точно відповідати імені файлу маршруту (без розширення .tsx). Властивість options дозволяє задати параметри відображення заголовка, анімацій та кнопок.'card' (стандартне) виконує класичний горизонтальний зсув картки праворуч. Значення 'modal' транслює екран у модальне вікно, що виїжджає знизу екрана (глибше розглядається в наступних розділах).UINavigationBar / Toolbar). Якщо екран використовує кастомний заголовок, зверстаний засобами React Native, цей параметр встановлюють у false.Механіка обробки системного повернення «Назад»
Нативна інтеграція Stack-навігатора забезпечує узгоджену поведінку з платформовими гайдлайнами:
- iOS: Підтримується плавний інерційний жест повернення змахуванням від лівого краю екрана (Interactive Swipe-to-Back) та нативна кнопка повернення у смузі заголовка.
- Android: Навігатор автоматично перехоплює натискання системної апаратної/програмної кнопки «Назад» або сучасний жест від краю екрана (Back Gesture), коректно викликаючи операцію
popдля активного стека.
Програмний виклик методу router.back() ініціює аналогічну операцію: перевіряє історію переходів і повертає користувача на один рівень назад.
Навігатор Tabs: Модель паралельних розділів інтерфейсу
Навігатор вкладок (Bottom Tabs Navigator) організовує кілька рівноправних функціональних доменів застосунку, перемикання між якими здійснюється через фіксовану панель у нижній частині дисплея.
На відміну від Stack-навігатора, перемикання між вкладками не створює лінійної стопки переходів. Кожна вкладка є автономним кореневим простором, який функціонує паралельно з іншими.
НАВІГАЦІЙНЕ ДЕРЕВО ВКЛАДОК
+------------------------------------+
| Tabs Navigator |
+------------------------------------+
/ | \
/ | \
+-----------------+ +-----------------+ +-----------------+
| Вкладка: | | Вкладка: | | Вкладка: |
| «Поїздки» (/) | | «Місця» (/places)| | «Ще» (/about) |
+-----------------+ +-----------------+ +-----------------+
Ергономічні вимоги до панелі вкладок:
- Кожна вкладка повинна містити чітку семантичну векторну іконку та лаконічний підпис (до 10–12 символів).
- Активний стан вкладки зобов'язаний візуально виділятися акцентним кольором теми бренду (
tabBarActiveTintColor). - Перемикання вкладок повинно зберігати внутрішній стан кожної з них (наприклад, поточну позицію скролу списку поїздок при переході на вкладку місць і назад).
import { Tabs } from 'expo-router'). Файл _layout.tsx усередині директорії вкладок експортує компонент, що повертає розмітку <Tabs>…</Tabs>.name задає ім'я цільового файлу маршруту (index, places, about).color) та рекомендований геометричний розмір (size) залежно від активності вкладки.colors.primary та colors.textSecondary).Інтеграція векторних іконок (@expo/vector-icons)
Для оформлення таб-бару використовується бібліотека @expo/vector-icons, яка постачається з популярними наборами гліфів (Ionicons, MaterialIcons, Feather, FontAwesome):
npx expo install @expo/vector-icons
Приклад конфігурації іконки у вкладці:
<Tabs.Screen
name="places"
options={{
title: 'Місця',
tabBarIcon: ({ color, size }) => (
<Ionicons name="location-outline" size={size} color={color} />
),
}}
/>
Довідник screenOptions: Конфігурація заголовків, стека та вкладок
У проєктах на базі Expo Router конфігурація візуального відображення та поведінки екранів здійснюється за допомогою універсального словника параметрів screenOptions (на рівні макета-навігатора) або властивості options (на рівні конкретного компонента <Stack.Screen /> чи <Tabs.Screen />).
Розуміння повної карти полів screenOptions критично важливе для побудови професійних інтерфейсів: від нативних розмитих шапок у стилі iOS до адаптивних таб-барів, системних статус-барів і модальних переходів.
Ієрархія та каскад спадкування конфігурацій
Налаштування екранів застосовуються за трирівневою каскадною моделлю з чітким пріоритетом:
[Рівень 1: Глобальні screenOptions] ──▶ [Рівень 2: Декларативні options екрана] ──▶ [Рівень 3: Рантайм setOptions()]
(Базові стилі для всього стека) (Перевизначення для конкретного маршруту) (Динамічні зміни під час роботи)
1. Загальні параметри нативного заголовка (Header Screen Options)
Ці параметри доступні як у Stack, так і в Tabs та відповідають за рендеринг верхньої навігаційної смуги (UINavigationBar в iOS / Toolbar в Android):
false повністю приховує заголовок операційної системи, передаючи керування верхньою частиною екрана верстці React Native або компоненту SafeAreaView.fontSize, fontWeight, fontFamily, color, letterSpacing.headerLeft/headerRight та стандартного заголовка.backgroundColor, elevation (Android), shadowOpacity (iOS), borderBottomWidth, borderBottomColor.true контент починається з верхнього краю дисплея y = 0 (ідеально для екранів з фотообкладинками та мапами).headerTransparent: true).BlurView з expo-blur або градієнта LinearGradient).false, кнопка приховується навіть за наявності попередніх екранів у стеку.false, відображається лише шеврон без тексту).false робить перехід між заголовком і тілом екрана безшовним.fontSize: 34, fontWeight: '700').UISearchController на iOS / SearchView на Android). Приймає об'єкт з полями placeholder, onChangeText, onSearchButtonPress, hideWhenScrolling, tintColor.2. Специфічні параметри стекового навігатора (Stack Screen Options)
Ці параметри конфігурують поведінку LIFO-стека, модальність вікон, системні статус-бари та анімації переходу нативного стека (@react-navigation/native-stack):
'card'— стандартний екран стека з горизонтальним зсувом;'modal'— нативне модальне вікно (на iOS з'являється знизу з ефектом масштабування батьківського екрана);'transparentModal'— модальне вікно з прозорим фоном, крізь який видно попередній екран;'fullScreenModal'— модальне вікно, що перекриває весь дисплей включно зі статус-баром;'formSheet'— модальний лист у стилі iPad/планшетів.
'slide_from_right'— класичний перехід Android/iOS;'slide_from_bottom'— виїзд знизу;'fade'— плавне згасання/поява;'none'— миттєве перемикання без анімації.
backgroundColor: colors.background). Запобігає білим спалахам під час анімацій у темній темі.'light' — білий текст для темного фону, 'dark' — темний текст для світлого).react-freeze: заморожує повторний рендеринг компонентів екрана, коли він перекритий іншим екраном стека.3. Специфічні параметри панелі вкладок (Bottom Tabs Options)
Ці параметри керують конфігурацією нижньої панелі вкладок <Tabs /> (@react-navigation/bottom-tabs):
false, таб-бар відображатиме виключно іконки.title спеціально для нижньої панелі).fontSize, fontWeight, fontFamily).focused, розмір size (зазвичай 24–28) та колір color.3 або точка '!').backgroundColor, color, fontSize.backgroundColor, height, borderTopColor, position: 'absolute', bottom, borderRadius (для створення плаваючого таб-бару з тінню).BlurView для напівпрозорого розмиття контенту під панеллю).false (стан та скрол зберігаються); встановлення true очищує пам'ять та скидає стан екрана.4. Динамічна функціональна форма screenOptions та хук useNavigation
Параметр screenOptions може приймати не лише статичний об'єкт, а й функцію зворотного виклику, яка отримує поточний маршрут (route) та об'єкт навігації (navigation):
<Stack
screenOptions={({ route }) => ({
headerTitle: route.name === 'create-trip' ? 'Створення' : 'Nomad',
headerShown: !route.name.startsWith('('),
})}
/>
Крім того, будь-який компонент екрана всередині app/ може динамічно оновлювати власні options під час роботи за допомогою хука useNavigation:
import { useLayoutEffect } from 'react';
import { useNavigation } from 'expo-router';
import { Pressable, Text } from 'react-native';
export default function EditTripScreen() {
const navigation = useNavigation();
useLayoutEffect(() => {
navigation.setOptions({
title: 'Редагування поїздки',
headerRight: () => (
<Pressable onPress={() => alert('Збережено!')}>
<Text style={{ color: '#2563eb', fontWeight: '600' }}>Готово</Text>
</Pressable>
),
});
}, [navigation]);
return null;
}
Логічне групування маршрутів: Семантика директорій (group)
У структурі файлового роутера назви директорій, взяті в круглі дужки, наприклад app/(tabs)/ або app/(auth)/, називаються групами маршрутів (Route Groups).
Круглі дужки є спеціальним синтаксичним маркером, який повідомляє компілятору Expo Router: ця директорія слугує виключно для логічної організації файлів та ізоляції макетів, але не додає власний сегмент до фінального рядка URL-маршруту.
ФАЙЛОВА СТРУКТУРА НА ДИСКУ РЕЗУЛЬТУЮЧИЙ МАРШРУТ (URL)
+-----------------------------------+ +-----------------------------------+
| app/ | | |
| └── (tabs)/ | --> | (Сегмент "(tabs)" ігнорується) |
| ├── index.tsx | --> | "/" |
| ├── places.tsx | --> | "/places" |
| └── about.tsx | --> | "/about" |
+-----------------------------------+ +-----------------------------------+
Якби директорія називалася без дужок app/tabs/places.tsx, фінальний маршрут набув би вигляду /tabs/places, що створювало б зайву вкладеність в адресації.
Архітектурні сценарії використання груп:
- Ізоляція макетів (Layout Scoping): Об'єднання екранів, які повинні мати спільну панель вкладок
Tabs, окремо від екранів, які повинні відображатися на весь дисплей уStack. - Сегрегація авторизаційних потоків: Створення груп
(auth)(для екранів входу та реєстрації зі своїм макетом) та(app)(для основної робочої зони авторизованого користувача). - Підтримка чистоти кореневого простору імен: Дозволяє утримувати лаконічні та семантично чисті URL першого рівня (
/,/places,/profile), одночасно структурувавши десятки файлів проєкту за окремими підкаталогами.
Програмне та декларативне керування переходами: Link та useRouter
Expo Router надає два взаємодоповнюючі механізми для реалізації переходів між маршрутами: декларативний (компонент Link) та імперативний (хук useRouter).
1. Декларативний підхід: компонент Link
Компонент Link призначений для розміщення навігаційних посилань безпосередньо в JSX-дереві. Цей підхід подібний до веб-елемента гіперпосилання, забезпечуючи високу декларативність та повну підтримку систем доступності для екранних дикторів (Screen Readers).
Базовий синтаксис:
import { Link } from 'expo-router';
import { Text } from 'react-native';
<Link href="/create-trip">
<Text>Створити нову поїздку</Text>
</Link>
Властивість asChild та інтеграція з кастомними кнопками
За замовчуванням компонент Link огортає переданий дочірній текст власним текстовим представленням. Якщо розробнику необхідно надати посиланню вигляд складної кнопки (Pressable), картки або інтерактивного блоку зі стилями, застосовується властивість asChild.
Властивість asChild повідомляє Link, що він не повинен створювати додатковий DOM/Native-вузол у дереві, а натомість зобов'язаний передати всі необхідні навігаційні обробники подій дотику (onPress) та атрибути доступності безпосередньо своєму першому дочірньому елементу:
import { Link } from 'expo-router';
import { Pressable, StyleSheet, Text } from 'react-native';
<Link href="/create-trip" asChild>
<Pressable style={styles.actionButton}>
<Text style={styles.actionButtonText}>Нова поїздка</Text>
</Pressable>
</Link>
2. Імперативний підхід: хук useRouter
Хук useRouter() повертає інкапсульований об'єкт маршрутизатора для програмного виконання переходів. Імперативна навігація застосовується у випадках, коли перехід є наслідком певної логічної операції, а не простого тапу на посилання:
- Після успішної валідації та відправки форми на сервер.
- У відповідь на асинхронні події (завершення таймера, отримання push-повідомлення).
- За необхідності попередньої перевірки умов авторизації перед відкриттям екрана.
Stack.push). Попередній екран залишається в історії, а на новому екрані автоматично стає доступною кнопка та жест повернення «Назад».router.back() закриває форму і показує оновлений список на вкладках.true, якщо в стеку навігації є хоча б один попередній екран, до якого можна повернутися. Запобігає виклику back() в умовах порожнього стека (наприклад, при прямому холодному старті за Deep Link).Приклад застосування useRouter в обробнику подій:
import { useRouter } from 'expo-router';
export function CreateTripController() {
const router = useRouter();
const handleFormComplete = async (tripData: unknown) => {
// 1. Асинхронне збереження даних
await saveTrip(tripData);
// 2. Програмне повернення до попереднього списку
if (router.canGoBack()) {
router.back();
} else {
router.replace('/');
}
};
return <TripForm onSubmit={handleFormComplete} />;
}
Матриця вибору навігаційного інструменту
| Сценарій використання | Рекомендований інструмент | Обґрунтування вибору |
|---|---|---|
| Елемент меню, посилання в тексті, статична картка | <Link href="..."> | Чітка декларативність у розмірці, підтримка Screen Reader. |
| Кнопка зі складним кастомним оформленням | <Link href="..." asChild><Pressable>…</Link> | Повне збереження кастомних стилів кнопки без спагеті-коду в onPress. |
| Колбек завершення відправки форми | router.back() | Перехід ініціюється тільки після успішного виконання асинхронного коду. |
| Завершення процедури авторизації / виходу | router.replace('/...') | Очищення навігаційного стека від екранів авторизації. |
Структура посилань href та статичні шляхи
Параметр href приймає рядок або об'єкт, який точно вказує цільову адресу маршруту в навігаційному дереві застосунку.
На етапі вивчення базової маршрутизації використовуються статичні шляхи:
"/"— кореневий маршрут (головний екран списку поїздок у групі(tabs));"/places"— екран каталогу локацій;"/about"— інформаційний екран «Про додаток»;"/create-trip"— стековий екран створення поїздки.
Динамічні параметризовані сегменти (наприклад, app/city/[id].tsx, де :id виступає змінною частиною URL) детально досліджуються у наступних розділах курсу.
href повинен гарантовано вказувати на існуючий фізичний файл у каталозі app/. Будь-яка механічна помилка в назві (наприклад, випадкова одруківка href="/crate-trip") призведе до помилки часу виконання «Unmatched Route» (маршрут не знайдено) та відображення білого екрана або системного екрана 404.Статична типізація навігаційних шляхів (Typed Routes)
Для запобігання помилкам, пов'язаним з ручним введенням рядків href, Expo Router пропонує інструмент компіляційного контролю — Typed Routes (Типізовані маршрути).
При активації цієї функції компілятор Expo Router автоматично сканує вміст директорії app/ і на льоту генерує TypeScript-декларації глобального об'єкта шляхів, типізуючи пропси компонента Link та параметри методів router.push/router.replace.
Активація типізації у файлі app.json:
{
"expo": {
"name": "Nomad",
"slug": "nomad",
"experiments": {
"typedRoutes": true
}
}
}
Після ввімкнення експериментального прапорця та перезапуску сервера розробки Metro (npx expo start), будь-яка спроба передати неіснуючий рядок шляху викличе помилку компілятора TypeScript ще до запуску застосунку на пристрої:
// TypeScript видасть помилку: Argument of type '"/unknown-screen"' is not assignable to parameter of type 'Href'
router.push('/unknown-screen');
// Успішна валідація з підтримкою автодоповнення в IDE:
router.push('/create-trip');
Координація нативних шарів: Header, SafeAreaView та розрахунок відступів
Під час конструювання мобільних інтерфейсів розробники регулярно стикаються з проблемою розрахунку верхніх і нижніх відступів під системні елементи пристрою: вирізи камер (Notch, Dynamic Island), рядок стану (Status Bar) та домашній індикатор жестів (Home Indicator).
Для ізоляції контенту від накладання на системні елементи використовується компонент SafeAreaView з бібліотеки react-native-safe-area-context. Проте при роботі з нативними навігаторами виникає специфічний конфлікт геометрії:
КОНФЛІКТ ПОДВІЙНОГО ВІДСТУПУ (DOUBLE INSET BUG)
+-------------------------------------------------------------------------+
| [Status Bar / Notch / Dynamic Island] |
| +---------------------------------------------------------------------+ |
| | Native Header (Вже містить власний безпечний відступ зверху) | |
| +---------------------------------------------------------------------+ |
| | [ЗАЙВИЙ ВІДСТУП!] SafeAreaView edges={['top']} додає ще 44–59 pt | |
| | +-----------------------------------------------------------------+ | |
| | | Контент форми (Невиправдано зміщений далеко вниз) | | |
| | +-----------------------------------------------------------------+ | |
+-------------------------------------------------------------------------+
Правила координації геометрії:
- Екрани з активним нативним заголовком (
headerShown: true): Нативний компонент заголовкаStack.Screenавтоматично резервує необхідний простір під системний статус-бар. Якщо контент такого екрана додатково обгорнути вSafeAreaViewз верхнім відступом, виникає дефект подвійного порожнього зазору (Double Padding Bug). Тому на таких екранах верхній край SafeAreaView повинен бути вимкнений (edges={['bottom', 'left', 'right']}). - Екрани з прихованим заголовком (
headerShown: false): На екранах вкладок(tabs), де системний заголовок вимкнено на користь власної верстки назви розділу, верхній відступ повинен повністю компенсуватися компонентомScreenабоSafeAreaView(edges={['top']}).
Архітектурні антипатерни та типові помилки проєктування навігації
1. Емуляція навігації через локальний стан useState('screen')
- Проблема: Спроба будувати навігацію великого додатку через умовний рендеринг за станом створює спагеті-код, руйнує кешування екранів, блокує апаратні жести повернення та унеможливлює Deep Linking.
- Рішення: Використовувати виключно декларативні можливості Expo Router (
app/,Stack,Tabs).
2. Концентрація складної бізнес-логіки у файлах маршрутів app/
- Проблема: Перетворення файлів у
app/на монолітні компоненти обсягом понад 500 рядків унеможливлює повторне використання UI-блоків та ізольоване тестування. - Рішення: Зберігати маршрути в
app/максимально тонкими, делегуючи верстку та бізнес-логіку модулям ізsrc/features/таsrc/shared/.
3. Плутанина між логічними групами (group) та фізичними сегментами URL
- Проблема: Очікування, що файл
app/(tabs)/places.tsxбуде доступний за маршрутом/tabs/places, і спроби звертатися до нього через цей некоректний шлях. - Рішення: Пам'ятати, що круглі дужки в іменах директорій виключаються з фінального рядка URL. Правильний шлях —
/places.
4. Виклик push('/') замість back() після успішного збереження форми
- Проблема: Якщо після збереження сутності у формі викликати
router.push('/'), навігатор не повернеться на існуючий екран, а створить і наштовхне новий дублікат головного екрана на вершину стека. При спробі користувача натиснути системну кнопку «Назад» він не вийде із застосунку, а повернеться назад на щойно заповнену форму. - Рішення: Після завершення роботи з формою завжди викликати
router.back()(абоrouter.replace('/'), якщо повернення в історію неприпустиме).
5. Ігнорування колірних токенів теми для навігаційних панелей
- Проблема: Застосунок підтримує темну тему, але панель вкладок залишається яскраво-білою за замовчуванням через відсутність конфігурації
tabBarStyleтаheaderStyle. - Рішення: Обов'язково зв'язувати параметри навігаторів (
tabBarStyle,headerStyle,tabBarActiveTintColor) із семантичними токенами дизайн-системи (colors.background,colors.border,colors.primary).
6. Вкладення модальних форм створення безпосередньо в список вкладок
- Проблема: Додавання форми
create-tripяк четвертої постійної вкладки в нижню панель спотворює UX: форма не є рівноправним розділом застосунку, а таб-бар заважає введенню даних. - Рішення: Організовувати форми як окремі екрани кореневого стека
Stack, що відкриваються поверх панелі вкладок.
Міні-проєкт: «Довідник міст»
Мета
Окремий застосунок поза 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
Вкладені навігатори та модалки
Архітектура ієрархічних навігаційних дерев у Expo Router — вкладені стеки у вкладках (Nested Stack in Tabs), модальні представлення (presentation modal), сегрегація маршрутів через групи (auth), перехоплення переходів через usePreventRemove, міні-проєкт «Нотатки» та екран деталей поїздки в Nomad