Deep linking і параметри маршрутів
Deep linking і параметри маршрутів
Вступ: Параметризація маршрутів та архітектура глибоких посилань (Deep Linking)
У попередніх розділах курсу було сформовано розвинену навігаційну структуру застосунку: кореневий стек Stack, панель вкладок Tabs, вкладений навігатор для детального перегляду та модальні оверлеї. При цьому перехід до конкретної поїздки здійснювався за допомогою виклику:
router.push(`/trips/${trip.id}`);
а цільовий екран вилучав ідентифікатор через призначений хук:
const { id } = useLocalSearchParams<{ id: string }>();
Цей механізм забезпечував базове функціонування інтерфейсу, проте залишав відкритими важливі питання роботи з параметрами: як передавати аргументи в рядку адреси, які існують відмінності між сегментами шляху (Path Parameters) та параметрами запиту (Query Parameters), як гарантувати типізацію та перевірку значень у TypeScript, а також як відкрити відповідний екран із зовнішніх посилань операційної системи.
У реальному мобільному продукті один і той самий екранний шаблон обслуговує безліч різних сутностей: сотні туристичних маршрутів, тисячі карток товарів чи профілів користувачів. Створення окремого статичного файлу під кожен екземпляр даних є неможливим. Замість цього розробник проєктує параметризований шаблон маршруту (Dynamic Route Component), який динамічно підставляє вміст на основі переданих аргументів.
Більше того, мобільний застосунок не існує у вакуумі. Користувач взаємодіє з посиланнями у сторонніх месенджерах, електронній пошті, системних push-повідомленнях, веб-браузерах та QR-кодах. Механізм Deep Linking (Глибоке зв'язування) дозволяє операційній системі перехопити зовнішнє посилання, запустити мобільний клієнт та автоматично перенаправити користувача безпосередньо до потрібного екрана з відновленням навігаційного контексту.
Навчальні цілі розділу:
- Зрозуміти відмінності між динамічними сегментами шляху (Path Params) та параметрами запиту (Query Params).
- Дослідити конвенцію синтаксису файлової системи
[param].tsxта зрозуміти алгоритм зіставлення динамічних шаблонів із фізичними маршрутами. - Засвоїти механізми передачі параметрів через декларативний компонент
Linkта імперативний методrouter.push()(як у формі строкових URL-шаблонів, так і через типізовані конфігураційні об'єкти). - Опанувати методи безпечного читання та нормалізації параметрів за допомогою хуків
useLocalSearchParamsтаuseGlobalSearchParams, усуваючи ризики розбіжності типівstring | string[]. - Спроєктувати надійну архітектуру обробки відсутніх або невалідних ідентифікаторів (Missing Entity Fallback) без аварійного завершення застосунку.
- Налаштувати власну схему URL (Custom URL Scheme) у файлі
app.jsonта дослідити взаємодію з підсистемоюexpo-linking. - Проаналізувати життєвий цикл запуску застосунку: відмінності між холодним стартом (Cold Start) та теплим відновленням (Warm Start) при надходженні зовнішнього посилання.
- Ознайомитися зі стандартами безшовних посилань: Universal Links на iOS та Android App Links.
- Реалізувати навчальний міні-проєкт «Купон за лінком» та інтегрувати повноцінну підтримку Deep Linking у проєкт Nomad.
Види навігаційних параметрів: Динамічні сегменти проти Query-параметрів
У сучасних веб- та мобільних стандартах адресації дані, що передаються в маршруті, поділяються на дві строго розмежовані категорії:
1. Динамічні сегменти шляху (Path / Route Parameters)
Динамічний сегмент — це обов'язкова позиційна складова структури шляху URL, яка замінює змінний ідентифікатор ресурсу.
У файловій системі Expo Router динамічний сегмент позначається назвою файлу або каталогу у квадратних дужках: [id].tsx, [code].tsx, [userId]. Текст усередині дужок стає іменем ключа, під яким значення буде доступне в коді компонента:
app/(tabs)/trips/[id].tsx ===> Маршрут: /trips/:id ===> /trips/3 (id = "3")
Динамічний сегмент не створює нових файлів для кожного запису: це єдиний універсальний React-компонент, який монтується з різними аргументами ініціалізації.
2. Параметри запиту (Query / Search Parameters)
Query-параметри — це опційні пари «ключ-значення», які додаються в кінець адреси після символу знака питання (?) та розділяються амперсандом (&):
/trips/3?tab=places&highlight=p1
Query-параметри не впливають на вибір фізичного файлу компонента. Вони використовуються для передачі допоміжного контексту відображення: активної вкладки, фільтра списку, параметрів сортування або прапорця підсвічування конкретного елемента.
Порівняльна характеристика модальностей параметрів
| Критерій | Динамічний сегмент шляху (Path Param) | Параметр запиту (Query Param) |
|---|---|---|
| Синтаксичне розташування | У тілі шляху: /trips/3 | Після розділювача ?: ?tab=places |
| Відображення у FS | Вимагає наявності файлу в дужках [id].tsx | Використовує існуючий файл без модифікації FS |
| Семантичне призначення | Ідентичність сутності (який об'єкт завантажити) | Опції представлення (фільтр, вкладка, пагінація) |
| Обов'язковість | Обов'язковий (без нього маршрут не співпаде) | Опційний (компонент зобов'язаний мати дефолтні значення) |
| Приклад використання | Ідентифікатор поїздки id="3" | Режим відображення view="grid", tab="places" |
Усі параметри (як path, так і query) агрегуються навігаційним рушієм Expo Router в єдиний нормалізований об'єкт, доступний через хук useLocalSearchParams().
Декларація динамічних маршрутів: Файлова конвенція [id].tsx
У проєкті Nomad структура маршрутів організована наступним чином:
app/(tabs)/trips/
_layout.tsx ← Вкладений Stack-навігатор
index.tsx ← Маршрут "/trips" (Стрічка поїздок)
[id].tsx ← Динамічний маршрут "/trips/:id"
Оскільки група (tabs) у круглих дужках ігнорується маршрутизатором при побудові адрес, результуючий шлях містить тільки сегмент каталогу trips:
- Перехід до стрічки:
/trips - Перехід до деталей поїздки з ID
42:/trips/42
Назва файлу [id].tsx визначає ім'я властивості. Якби файл було названо [tripId].tsx, адреса залишалася б незмінною (/trips/42), проте об'єкт параметрів містив би поле { tripId: "42" }.
id vs tripId). Неузгодженість найменування є однією з найпоширеніших причин появи undefined у рантаймі.Вкладені динамічні сегменти
Для моделювання складних реляційних зв'язків Expo Router підтримує довільну глибину вкладеності динамічних сегментів:
app/trips/[tripId]/places/[placeId].tsx ===> /trips/3/places/p1
У цьому випадку хук useLocalSearchParams поверне об'єкт із двома ключами: { tripId: "3", placeId: "p1" }.
Механізми передачі параметрів: Рядкова адресація проти типізованих об'єктів
Expo Router надає два рівноцінних підходи до передачі параметрів під час навігації.
Спосіб А. Рядкова інтерполяція (Template Literals)
Найбільш наочний та лаконічний метод, ідентичний формуванню гіперпосилань у веб-середовищі:
// Імперативний перехід
router.push(`/trips/${trip.id}`);
// Декларативний перехід
<Link href={`/trips/${trip.id}?tab=places`}>Переглянути місця</Link>
- Переваги: Максимальна візуальна наочність сформованого URL.
- Недоліки: Ризик помилок у ручній конкатенації слешів; необхідність ручного кодування спецсимволів через
encodeURIComponentу випадку передачі довільних текстових рядків.
Спосіб Б. Структурований конфігураційний об'єкт { pathname, params }
Більш надійний інженерний підхід, рекомендований для реальних проєктів зі строгою типізацією:
// Імперативний виклик з типізацією
router.push({
pathname: '/trips/[id]',
params: {
id: trip.id,
tab: 'places', // Додатковий ключ автоматично стає Query-параметром
},
});
// Декларативний компонент Link
<Link
href={{
pathname: '/trips/[id]',
params: { id: trip.id },
}}
>
Відкрити поїздку
</Link>
- Властивість
pathnameприймає шаблон маршруту у точності так, як він описаний у файловій системі (/trips/[id]). - Властивість
paramsмістить словник значень. Рушій Expo Router самостійно підставляє значенняidу шаблон шляху, а всі нерозпізнані ключі (наприклад,tab) автоматично серіалізує в рядок запиту?tab=places.
Архітектурне правило: Передача ідентифікаторів замість серіалізації об'єктів
Усі дані, передані через params, серіалізуються в рядковий формат URL.
НЕКОРЕКТНО (АНТИПАТЕРН) АРХІТЕКТУРНО ПРАВИЛЬНО
+------------------------------------+ +-----------------------------------------+
| router.push({ | | router.push(`/trips/${trip.id}`); |
| pathname: '/details', | vs | |
| params: { trip: JSON.stringify()}| | // Екран деталей: |
| }); | | const { id } = useLocalSearchParams(); |
| - Ліміти довжини URL | | const trip = useTrips().getTrip(id); |
| - Помилки парсингу при спецсимволах| | • Легкий та чистий URL |
| - Неможливо відкрити з Deep Link | | • Повна сумісність з Deep Linking |
+------------------------------------+ +-----------------------------------------+
Категорично заборонено передавати великі структури даних або серіалізовані JSON-об'єкти через параметри маршруту (?data={"title":"..."}). Навігаційний маршрут повинен містити виключно скалярний ідентифікатор ресурсу (id). Екран деталей зобов'язаний самостійно вилучити повний об'єкт із глобального сховища (React Context, Redux, Zustand) або виконати асинхронний запит до API. Тільки такий підхід гарантує повну працездатність застосунку при переході за зовнішнім посиланням Deep Link.
Десеріалізація та валідація параметрів: Хуки useLocalSearchParams та useGlobalSearchParams
Для вилучення параметрів активного маршруту Expo Router надає два спеціалізовані React-хуки.
1. useLocalSearchParams
Основний інструмент для більшості компонентів екранів. Повертає параметри, область видимості яких обмежена поточним активним маршрутом:
import { useLocalSearchParams } from 'expo-router';
export default function TripDetailsScreen() {
const { id, tab } = useLocalSearchParams<{ id: string; tab?: string }>();
// ...
}
2. useGlobalSearchParams
Повертає глобальний знімок параметрів усього активного навігаційного графа. Використовується у спільних компонентах верхнього рівня (наприклад, у глобальному _layout.tsx чи навігаційному заголовку), коли необхідно відстежувати параметри дочірніх екранів без прямого прокидання пропсів.
Проблема типізації string | string[] та техніка нормалізації
У специфікації URL той самий параметр може зустрічатися кілька разів (наприклад, ?tag=nature&tag=mountains), внаслідок чого парсер транслює його в масив рядків string[]. Крім того, внутрішні механізми перекодування Expo Router у специфічних сценаріях можуть повертати значення як масив.
Спроба безпосереднього звернення до методів рядка без попередньої перевірки типу може спричинити фатальну помилку виконання (TypeError: id.toUpperCase is not a function).
Надійний спосіб безпечної нормалізації параметрів:
export default function TripDetailsScreen() {
// 1. Декларація можливого масиву в типі
const { id } = useLocalSearchParams<{ id: string | string[] }>();
// 2. Атомарна нормалізація до скалярного рядка
const tripId = Array.isArray(id) ? id[0] : id;
// 3. Валідація наявності значення
if (!tripId) {
return <InvalidRouteView message="Ідентифікатор маршруту відсутній" />;
}
// 4. Безпечне використання гарантованого рядка
const trip = getTripById(tripId);
// ...
}
Патерн захисту від відсутності сутності (Missing Entity Fallback)
При проєктуванні системи параметризації необхідно розділяти два типи нештатних ситуацій:
- Помилка маршрутизації (Invalid Route): Параметр
idвзагалі відсутній у URL або сформований некоректно. - Відсутність сутності в джерелі даних (Entity Not Found): Параметр
idприсутній (наприклад,id="999"), проте відповідний запис відсутній у базі даних, був видалений на сервері або скинутий після скидання кешу.
Інтерфейс компонента зобов'язаний надійно обробляти обидва сценарії без виклику неочікуваних падінь (Uncaught Crashes):
if (!trip) {
return (
<View style={styles.centerContainer}>
<AppText variant="subtitle">Поїздку не знайдено</AppText>
<AppText variant="caption" color={colors.textSecondary}>
Запис із ідентифікатором {tripId} відсутній у системі або був видалений.
</AppText>
<Button label="Повернутися назад" onPress={() => router.back()} />
</View>
);
}
Архітектура Deep Linking: Інтеграція застосунку із зовнішнім середовищем ОС
Deep Link (Глибоке посилання) — це стандартизований механізм міжпроцесної взаємодії, за якого зовнішній клієнт (веб-браузер, поштовий клієнт, QR-сканер) передає операційній системі запит на відкриття конкретного екрана всередині цільового мобільного застосунку.
ЛАНЦЮГ ОБРОБКИ DEEP LINK
+---------------------------------------------------------------------------------+
| 1. ЗОВНІШНЄ ДЖЕРЕЛО: Клік на посилання nomad://trips/42 у месенджері чи QR |
+---------------------------------------------------------------------------------+
│
▼
+---------------------------------------------------------------------------------+
| 2. ОПЕРАЦІЙНА СИСТЕМА (iOS / Android): Пошук застосунку за зареєстрованою схемою|
+---------------------------------------------------------------------------------+
│
▼
+---------------------------------------------------------------------------------+
| 3. ШАР EXPO ROUTER: Десеріалізація шляху trips/42 -> маршрут app/(tabs)/trips/[id|
+---------------------------------------------------------------------------------+
│
▼
+---------------------------------------------------------------------------------+
| 4. ЕКРАН ДЕТАЛЕЙ: useLocalSearchParams() -> id="42" -> Завантаження даних з БД |
+---------------------------------------------------------------------------------+
Чому Expo Router ідеально адаптований для Deep Linking
У традиційних архітектурах без файлового роутингу розробник змушений писати громіздкі імперативні конфігурації парсингу URL (об'єкти linking.config у React Navigation з багаторівневими регулярними виразами).
В Expo Router зв'язування здійснюється автоматично: будь-який файл у каталозі app/ автоматично є готовою кінцевою точкою для зовнішнього посилання. Якщо файл app/(tabs)/trips/[id].tsx коректно функціонує під час внутрішніх переходів через router.push('/trips/42'), він автоматично коректно відкриється при надходженні зовнішнього посилання nomad://trips/42.
Реєстрація кастомних схем: Custom URL Scheme (nomad://)
Custom URL Scheme (Власна схема URL) — це кастомний протокол адресації (наприклад, nomad://, slack://, spotify://), що реєструється у маніфесті операційної системи під час встановлення застосунку.
Конфігурація схеми у файлі app.json:
{
"expo": {
"name": "Nomad",
"slug": "nomad",
"scheme": "nomad"
}
}
Параметр "scheme": "nomad" транслюється компілятором у відповідні нативні конфігурації:
- iOS: Секція
CFBundleURLTypesу файліInfo.plist. - Android: Секція
<intent-filter>з категоріямиDEFAULTтаBROWSABLEу файліAndroidManifest.xml.
Структура рядка Custom Scheme
nomad://trips/42?tab=places
│ │ │ │
│ │ │ └── Query-параметр (додаткові опції)
│ │ └── Динамічний аргумент сегмента [id]
│ └── Сегмент каталогу у структурі app/
└── Зареєстрована схема протоколу (scheme)
myapp://, поведінка операційної системи стає недетермінованою. Для виключення колізій використовуйте унікальні брендовані схеми (наприклад, company-nomad://).Підсистема expo-linking: Генерація та обробка платформових посилань
Для програмної взаємодії з URL-адресами в екосистемі Expo використовується офіційний модуль expo-linking.
Основні методи API expo-linking:
- У середовищі Expo Go повертає внутрішній URL формату
exp://192.168.1.5:8081/--/trips/42. - У нативній збірці повертає кастомний URL формату
nomad://trips/42. Дозволяє безпечно додавати Query-параметри через властивістьoptions.queryParams.
scheme, hostname, path та queryParams.Приклад генерації посилання для кнопки «Поділитися поїздкою»:
import * as Linking from 'expo-linking';
import { Share } from 'react-native';
export function ShareTripButton({ tripId, title }: { tripId: string; title: string }) {
const handleShare = async () => {
// Генерація середовищно-незалежного посилання
const shareableUrl = Linking.createURL(`/trips/${tripId}`);
await Share.share({
message: `Перегляньте план моєї подорожі «${title}»: ${shareableUrl}`,
url: shareableUrl, // для платформи iOS
});
};
return <Button label="Поділитися" onPress={handleShare} />;
}
Практична верифікація та тестування URL-схем
Для тестування обробки глибоких посилань на симуляторах та фізичних пристроях використовуються нативні системні утиліти командного рядка:
1. Тестування на iOS Simulator через simctl
# Формат: xcrun simctl openurl <device_id|booted> <url>
xcrun simctl openurl booted "nomad://trips/1"
2. Тестування на Android Emulator через adb
# Відправка системного наміру android.intent.action.VIEW
adb shell am start -a android.intent.action.VIEW -d "nomad://trips/1"
Життєвий цикл запуску: Холодний старт (Cold Start) проти теплого відновлення (Warm Start)
Поведінка підсистеми маршрутизації суттєво різниться залежно від поточного стану мобільного процесу в оперативній пам'яті:
ХОЛОДНИЙ СТАРТ (COLD START)
+---------------------------------------------------------------------------------+
| 1. Процес застосунку відсутній у RAM -> ОС виділяє ресурси |
| 2. Ініціалізація нативного рушія та середовища виконання JS |
| 3. Монтування кореневого дерева: app/_layout.tsx -> ThemeProvider, TripsProvider|
| 4. Отримання вхідного URL -> Навігація до цільового екрана app/(tabs)/trips/[id]|
| 5. Отримання даних: Сховище TripsProvider зобов'язане мати початковий стан! |
+---------------------------------------------------------------------------------+
ТЕПЛЕ ВІДНОВЛЕННЯ (WARM START)
+---------------------------------------------------------------------------------+
| 1. Процес уже знаходиться у фоні RAM з активним станом React |
| 2. ОС генерує подію AppState / Linking -> URL передається в існуючий контекст |
| 3. Expo Router оновлює активний маршрут без перезавантаження кореневих макетів |
+---------------------------------------------------------------------------------+
getTripById(id) поверне undefined. Тому стан за замовчуванням у провайдерах повинен містити базові початкові дані (Seed Data), зчитувати кеш із персистентного сховища або негайно ініціювати фоновий мережевий запит.Захищена безшовна навігація: Universal Links (iOS) та Android App Links
Незважаючи на простоту налаштування, Custom URL Schemes мають істотні обмеження:
- Якщо застосунок не встановлено на пристрої, клік на посилання
nomad://trips/1у браузері призведе до помилки невідомого протоколу. - Неможливо гарантувати унікальність схеми серед тисяч інших застосунків.
Для подолання цих обмежень платформові гіганти Apple та Google розробили технології захищеного безшовного зв'язування на основі стандартних HTTPS-доменів: Universal Links (iOS) та App Links (Android).
Порівняння технологій доставки посилань
| Параметр | Custom URL Scheme (nomad://) | Universal Links / App Links (https://) |
|---|---|---|
| Формат адреси | nomad://trips/3 | https://nomad.example.com/trips/3 |
| Поведінка без додатку | Помилка операційної системи | Безшовний перехід на веб-сайт (Web Fallback) |
| Верифікація безпеки | Відсутня (будь-який додаток може заявити схему) | Криптографічна двостороння верифікація |
| Вимоги до інфраструктури | Лише запис у app.json | Власний HTTPS-домен зі спеціальними JSON-файлами |
Механізм двосторонньої верифікації (Two-Way Handshake)
Операційна система відкриває HTTPS-посилання всередині мобільного застосунку тільки після того, як успішно перевірить взаємну довіру між сервером і бінарним файлом:
- Сертифікація на боці сервера:
На публічному веб-сервері за захищеним протоколом HTTPS розміщуються цифрові маніфести:
- iOS:
https://nomad.example.com/.well-known/apple-app-site-association(міститьTeamID.bundleIdentifierта список дозволених шляхів/trips/*). - Android:
https://nomad.example.com/.well-known/assetlinks.json(міститьpackage_nameта SHA-256 відбиток цифрового підпису додатку).
- iOS:
- Конфігурація у застосунку:
У файлі
app.jsonдекларується прив'язка до домену:ios.associatedDomains: ["applinks:nomad.example.com"]android.intentFiltersз параметромautoVerify: true.
Після встановлення додатку операційна система самостійно завантажує маніфест із домену, зіставляє цифрові підписи й надалі перенаправляє всі запити до https://nomad.example.com/trips/* безпосередньо у мобільний клієнт.
Архітектурні антипатерни та типові помилки при роботі з Deep Links і параметрами
1. Серіалізація складних об'єктів у параметри маршруту
- Проблема: Передача всього об'єкта сутності через JSON у параметрах запиту (
/trips/new?data={...}). - Рішення: Передавати виключно мінімальний унікальний ідентифікатор (
id), вилучаючи повні дані з доменного шару сховища або API.
2. Відсутність нормалізації типів string | string[]
- Проблема: Пряме використання параметра
idбез перевіркиArray.isArray(), що призводить до падіння програми при отриманні масиву. - Рішення: Завжди застосовувати атомарну нормалізацію:
const tripId = Array.isArray(id) ? id[0] : id;.
3. Відсутність обробки відсутності сутності (Missing Entity Crash)
- Проблема: Спроба прямого рендерингу
trip.titleза умови, колиgetTripByIdповернувundefined(наприклад, застарілий Deep Link). - Рішення: Реалізувати обов'язковий декларативний блок заглушки «Сутність не знайдено» з можливістю повернення назад.
4. Хардкодинг Custom Schemes замість використання Linking.createURL
- Проблема: Ручне формування рядка
nomad://trips/1у функціях «Поділитися», що ламає роботу в тестовому середовищі Expo Go. - Рішення: Формувати всі вихідні посилання виключно через фабричний метод
Linking.createURL('/trips/' + id).
5. Неузгодженість файлової структури та маршрутів Deep Link
- Проблема: Спроба надіслати посилання
nomad://trip/1(однина) за наявності файлової структуриapp/(tabs)/trips/[id].tsx(множина), що викликає помилку «Unmatched Route». - Рішення: Утримувати строгу відповідність між іменами каталогів у
app/та документацією публічних URL-маршрутів.
Міні-проєкт: «Купон за лінком» (від А до Я)
Мета
Окремий застосунок поза Nomad. Відкриваєте couponapp://coupon/SUMMER25 (або createURL) → екран купона з кодом. Повний код нижче.
Що має вийти
- Головний екран зі списком купонів (тап → деталі).
- Екран
/coupon/[code]— великі літери коду, опис, кнопка «Скопіювати» (опційно Alert). - Кнопка «Відкрити deep link» через
Linking.openURL(createURL(...)). - Невідомий код → спокійний «Купон не знайдено».
- У
app.json—"scheme": "couponapp".
Структура
coupon-app/
app/
_layout.tsx
index.tsx
coupon/
[code].tsx
src/
data/
coupons.ts
app.json
package.json
Крок 1. Проєкт
1. Скарфолд
npx create-expo-app@latest coupon-app -t blank-typescript
cd coupon-app
2. Router і залежності
npx expo install expo-router expo-linking expo-constants expo-status-bar react-native-safe-area-context react-native-screens
У package.json встановіть:
"main": "expo-router/entry"
3. Scheme
У app.json всередині "expo":
"scheme": "couponapp",
"plugins": ["expo-router"]
4. Запуск після файлів
npx expo start
Крок 2. Дані
Створіть src/data/coupons.ts:
export type Coupon = {
code: string;
title: string;
description: string;
discountLabel: string;
};
export const coupons: Coupon[] = [
{
code: 'SUMMER25',
title: 'Літо −25%',
description: 'Знижка на будь-який тур з виїздом у червні–серпні.',
discountLabel: '−25%',
},
{
code: 'WELCOME10',
title: 'Вітальний',
description: 'Для першої поїздки в застосунку.',
discountLabel: '−10%',
},
{
code: 'CITY50',
title: 'Міський вікенд',
description: '50 грн на каву в партнера (навчальний купон).',
discountLabel: '50 ₴',
},
];
export function getCoupon(code: string): Coupon | undefined {
const normalized = code.trim().toUpperCase();
return coupons.find((c) => c.code === normalized);
}
Крок 3. 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: { color: '#0F172A', fontWeight: '600' },
contentStyle: { backgroundColor: '#F8FAFC' },
}}
>
<Stack.Screen name="index" options={{ title: 'Купони' }} />
<Stack.Screen name="coupon/[code]" options={{ title: 'Купон' }} />
</Stack>
</>
);
}
Крок 4. Головна — app/index.tsx
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { Link, useRouter } from 'expo-router';
import * as Linking from 'expo-linking';
import { SafeAreaView } from 'react-native-safe-area-context';
import { coupons } from '../src/data/coupons';
export default function HomeScreen() {
const router = useRouter();
const openDeepLink = async (code: string) => {
const url = Linking.createURL(`/coupon/${code}`);
await Linking.openURL(url);
};
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<Text style={styles.lead}>
Тап по картці — звичайний push. Кнопка «Deep link» — через
Linking.openURL(createURL), ніби ззовні.
</Text>
<Text style={styles.mono}>scheme: couponapp</Text>
<FlatList
data={coupons}
keyExtractor={(item) => item.code}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={{ height: 10 }} />}
renderItem={({ item }) => (
<View style={styles.card}>
<Link href={`/coupon/${item.code}`} asChild>
<Pressable>
<Text style={styles.cardTitle}>{item.title}</Text>
<Text style={styles.code}>{item.code}</Text>
<Text style={styles.muted}>{item.discountLabel}</Text>
</Pressable>
</Link>
<Pressable
style={styles.secondaryBtn}
onPress={() => openDeepLink(item.code)}
>
<Text style={styles.secondaryBtnText}>Відкрити deep link</Text>
</Pressable>
</View>
)}
/>
<Pressable
style={styles.ghost}
onPress={() => router.push('/coupon/UNKNOWN')}
>
<Text style={styles.ghostText}>Тест: неіснуючий код</Text>
</Pressable>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#F8FAFC' },
lead: {
paddingHorizontal: 16,
paddingTop: 8,
color: '#64748B',
fontSize: 13,
lineHeight: 18,
},
mono: {
paddingHorizontal: 16,
marginTop: 6,
marginBottom: 8,
color: '#2563EB',
fontSize: 12,
fontWeight: '600',
},
list: { padding: 16, paddingBottom: 24 },
card: {
backgroundColor: '#fff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#E2E8F0',
padding: 14,
gap: 8,
},
cardTitle: { fontSize: 17, fontWeight: '700', color: '#0F172A' },
code: { fontSize: 20, fontWeight: '800', color: '#2563EB', letterSpacing: 1 },
muted: { color: '#64748B', fontSize: 13 },
secondaryBtn: {
marginTop: 4,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
borderColor: '#2563EB',
alignItems: 'center',
},
secondaryBtnText: { color: '#2563EB', fontWeight: '700' },
ghost: { padding: 16, alignItems: 'center' },
ghostText: { color: '#94A3B8', fontWeight: '600' },
});
Крок 5. Екран купона — app/coupon/[code].tsx
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import * as Linking from 'expo-linking';
import { getCoupon } from '../../src/data/coupons';
export default function CouponScreen() {
const { code } = useLocalSearchParams<{ code: string | string[] }>();
const raw = Array.isArray(code) ? code[0] : code;
const coupon = raw ? getCoupon(raw) : undefined;
const router = useRouter();
const deepLink = raw
? Linking.createURL(`/coupon/${String(raw).toUpperCase()}`)
: '';
if (!coupon) {
return (
<View style={styles.center}>
<Stack.Screen options={{ title: 'Не знайдено' }} />
<Text style={styles.title}>Купон не знайдено</Text>
<Text style={styles.muted}>Код: {String(raw)}</Text>
<Pressable style={styles.btn} onPress={() => router.back()}>
<Text style={styles.btnText}>Назад</Text>
</Pressable>
</View>
);
}
return (
<View style={styles.body}>
<Stack.Screen options={{ title: coupon.title }} />
<Text style={styles.discount}>{coupon.discountLabel}</Text>
<Text style={styles.code}>{coupon.code}</Text>
<Text style={styles.desc}>{coupon.description}</Text>
<Text style={styles.muted}>Deep link цього екрана:</Text>
<Text style={styles.link}>{deepLink}</Text>
<Pressable
style={styles.btn}
onPress={() => Alert.alert('Купон', `Умовно застосовано ${coupon.code}`)}
>
<Text style={styles.btnText}>Застосувати</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
body: { flex: 1, padding: 24, backgroundColor: '#F8FAFC', gap: 10 },
center: {
flex: 1,
padding: 24,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F8FAFC',
gap: 8,
},
title: { fontSize: 22, fontWeight: '800', color: '#0F172A' },
discount: { fontSize: 36, fontWeight: '800', color: '#16A34A' },
code: {
fontSize: 28,
fontWeight: '800',
color: '#2563EB',
letterSpacing: 2,
},
desc: { fontSize: 16, lineHeight: 24, color: '#334155', marginTop: 8 },
muted: { color: '#64748B', fontSize: 13, marginTop: 16 },
link: { color: '#0F172A', fontSize: 12, fontWeight: '600' },
btn: {
marginTop: 24,
backgroundColor: '#2563EB',
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
alignSelf: 'stretch',
},
btnText: { color: '#fff', fontWeight: '800' },
});
Крок 6. Перевірка
- Список купонів → тап → екран коду.
- «Відкрити deep link» → той самий екран через linking.
- Неіснуючий код → «не знайдено».
- На білді з scheme:
xcrun simctl openurl booted "couponapp://coupon/SUMMER25"
або adb зcouponapp://coupon/SUMMER25.
Критерій «готово»
-
scheme: couponapp - Повні файли з collapsible
- Path param
code+ нормалізація регістру -
createURL+openURL - Missing coupon UI
Nomad: typed routes і deep link на поїздку
Навіщо користувачу
Поділитись посиланням на конкретну поїздку (у навчанні — показати URL і відкрити його). Зовнішній світ передає лише id; екран деталей і getTrip уже вміють решту.
Нитка
Уже є: scheme: "nomad", typedRoutes: true, /trips/[id], useLocalSearchParams, nested stack, modal create-trip.
Додаємо:
- на екрані деталей — рядок
Linking.createURL('/trips/' + id)і кнопка «Відкрити це посилання»; - на вкладці «Ще» — підказка scheme + приклад createURL для
/trips/1.
Повний знімок проєкту
Як перевірити
- Відкрийте поїздку id
1зі списку — блок Deep link показує URL. - «Відкрити це посилання» — Router знову веде на деталі.
- На preview/dev build:
xcrun simctl openurl booted "nomad://trips/1"
або adb з тим самим URI. nomad://trips/nope→ «Поїздку не знайдено».- Вкладка «Ще» — текст scheme і createURL.
Коміт
cd /path/to/nomad
git add -A
git commit -m "$(cat <<'EOF'
feat: typed routes and deep link to trip
Material: content/15.react-native/13.deep-linking-and-params.md
EOF
)"
git push
Практичні завдання
Базовий рівень
- Своїми словами: path param vs query.
- Чим
nomad://trips/3відрізняється відhttps://…/trips/3для користувача без app. - Навіщо
Array.isArray(id) ? id[0] : id.
Середній рівень
- Міні-проєкт «Купон» повністю.
- У Nomad додати query
?from=shareу createURL і показатиfromна деталях. - Порівняти рядок createURL у Expo Go і (якщо є) у standalone.
Професійний рівень
- Чернетка
associatedDomains+ AASA paths для/trips/*(без обов’язкового деплою). - Описати cold start: що буде з trips, якщо прибрати mock seed (гіпотетично).
- Список ризиків: відкритий id у URL, enumeration, застарілі посилання.
Часті запитання
/trips/…. Залогдайте Linking.parse(url).app/. Перевірте trips vs trip, перезапуск TS.Що далі
Модуль навігації закрито: params, scheme, уявлення про universal links, cold/warm start. Далі — мережа й життєвий цикл застосунку: fetch, стани помилок, AppState, офлайн-банер — щоб deep link на поїздку міг підвантажити дані не лише з mock.
Expo linking · iOS Universal Links · Android App Links · URL parameters · Nomad
Вкладені навігатори та модалки
Архітектура ієрархічних навігаційних дерев у Expo Router — вкладені стеки у вкладках (Nested Stack in Tabs), модальні представлення (presentation modal), сегрегація маршрутів через групи (auth), перехоплення переходів через usePreventRemove, міні-проєкт «Нотатки» та екран деталей поїздки в Nomad
Робота з мережею та життєвий цикл застосунку
Глибокий практичний розбір мережевого стека React Native (fetch, axios, нативні стеки NSURLSession та OkHttpClient), нормалізація серверного стану, класифікація та обробка помилок, експоненційний backoff з jitter, черги оновлення токенів, моніторинг з'єднання через NetInfo та керування життєвим циклом застосунку через AppState.