Deep linking і параметри маршрутів
Deep linking і параметри маршрутів
Параметри маршруту: навіщо вони взагалі
Зв’язок із тим, що вже є
У статтях 11–12 ми вже відкривали конкретну поїздку:
router.push(`/trips/${trip.id}`);
і на екрані деталей читали:
const { id } = useLocalSearchParams<{ id: string }>();
Тобто механізм params у проєкті вже живе. Але він з’явився «по дорозі» — рівно стільки, щоб деталі працювали. Тут ми розкладаємо його з нуля: що саме летить у маршруті, які бувають види параметрів, як їх передавати охайно і що ламається, якщо id немає або прийшов масивом.
Deep linking (відкриття застосунку ззовні за посиланням) — окрема надбудова над тими самими шляхами. Спочатку має сенс міцно зрозуміти params всередині застосунку; потім scheme і universal links стануть природним продовженням, а не магією.
Життєва сцена
Користувач у стрічці бачить двадцять карток. Усі вони ведуть на «той самий» тип екрана — деталі поїздки, — але з різним змістом. Питання до навігації: як сказати системі «відкрий шаблон деталей для поїздки 3», а не «відкрий один фіксований файл trip-carpathians.tsx»?
Відповідь у file-based router: один файл з динамічним сегментом у імені ([id].tsx) і значення цього сегмента в шляху (/trips/3).
На вебі ви вже бачили те саме:
/products/42/users/anna/settings/search?q=київ&page=2
На телефоні з Expo Router ідея близька: шлях описує який екран і з якими даними його налаштувати. Відмінність середовища — звідки може прийти цей шлях (тап у застосунку, пуш, QR, Safari), і як ОС віддає його вашому JS. Спочатку розберемо що всередині шляху; відкриття ззовні (scheme, universal links) — логічне продовження тих самих адрес.
Після статті ви зможете:
- Пояснити різницю між динамічним сегментом і query-параметром.
- Зв’язати ім’я файлу
[id].tsxзі шляхом/trips/3. - Передати params через
Link,router.push(рядок і об’єкт). - Прочитати params через
useLocalSearchParamsі безпечно нормалізуватиstring | string[]. - Обробити «немає id» / «поїздки немає в даних» без падіння застосунку.
- Коротко відрізнити local і global search params.
- Відкрити маршрут ззовні через scheme / links і врахувати cold start.
Два види параметрів
1. Динамічний сегмент шляху (path / route param)
Динамічний сегмент — частина шляху, яка змінюється і обирає ресурс.
Приклади:
| Шлях | Файл (схема) | Параметр |
|---|---|---|
/trips/3 | …/trips/[id].tsx | id = "3" |
/trips/local-1710 | той самий файл | id = "local-1710" |
/city/lviv (міні-проєкт ст. 11) | city/[id].tsx | id = "lviv" |
У імені файлу сегмент пишуть у квадратних дужках: [id], [slug], [userId]. Ім’я в дужках стає ключем у об’єкті params.
Це не «папка на всі id». Це один React-екран-шаблон, який Router відкриває знову і знову з різним id.
2. Query-параметри (search / query params)
Query — те, що йде після ?, у вигляді ключ=значення, кілька пар через &:
/trips/3?tab=places
/trips/3?tab=places&highlight=p1
Вони не створюють новий файл маршруту. Той самий [id].tsx може прочитати tab і, наприклад, одразу розгорнути секцію «Місця».
На вебі query звичні для фільтрів і пагінації. У мобільному Router їх теж використовують, але рідше зловживають довгими рядками: зручніше тримати складний UI-стан у React state / глобальному store, а в params — короткі підказки (вкладка, id, прапорець).
Порівняння
| Динамічний сегмент | Query | |
|---|---|---|
| Де в URL | всередині path: /trips/3 | після ?: ?tab=places |
| Файл | потрібен [id].tsx | файл той самий |
| Типовий сенс | ідентичність ресурсу | опції показу |
| Обов’язковість | часто обов’язковий для маршруту | часто опційний |
| Приклад | яка поїздка | який таб на екрані деталей |
Обидва види потрапляють у search params хуків Expo Router (назви історичні: «search» з вебу). Тобто useLocalSearchParams() може повернути і id, і tab разом.
Файл [id].tsx і шлях
У Nomad уже є:
app/(tabs)/trips/
_layout.tsx
index.tsx → /trips (або /trips/ залежно від нормалізації)
[id].tsx → /trips/:id
Група (tabs) не з’являється в URL. Сегмент trips — з’являється, бо це звичайна тека, не в дужках.
Отже:
- список:
/trips - деталі поїздки з id
3:/trips/3
Ім’я параметра id взято з імені файлу [id]. Якби файл називався [tripId].tsx, у params був би ключ tripId, а шлях виглядав би так само структурно: /trips/3, але читали б const { tripId } = useLocalSearchParams().
id / tripId / tripID — джерело дрібних багів.Кілька динамічних сегментів
Можна глибше (на майбутнє):
app/trips/[tripId]/places/[placeId].tsx
→ /trips/3/places/p1
У частині 1 достатньо одного сегмента — як у Nomad. Головне зрозуміти правило: кожна [назва] у path ↔ ключ у params.
Як передати параметри
Спосіб A. Рядок у href / push (найнаочніший)
router.push(`/trips/${trip.id}`);
<Link href={`/trips/${trip.id}`}>Відкрити</Link>
Перевага: видно весь шлях. Недолік: легко помилитись у слешах; для typed routes інколи зручніший об’єкт (нижче).
Спосіб B. Об’єкт { pathname, params }
router.push({
pathname: '/trips/[id]',
params: { id: trip.id },
});
<Link
href={{
pathname: '/trips/[id]',
params: { id: trip.id },
}}
>
Відкрити
</Link>
Тут pathname — шаблон маршруту (як у файловій системі), а params заповнює динамічні шматки і може додати query.
Передача query:
router.push({
pathname: '/trips/[id]',
params: {
id: trip.id,
tab: 'places', // стане query, бо не є ім’ям сегмента [id] у path…
},
});
У Expo Router параметри, які не відповідають динамічному сегменту в path, зазвичай потрапляють у query-частину. Тобто tab не «замінить» id, а додасться як ?tab=places (поведінку завжди перевіряйте на своїй версії Router — ідея саме така: path params vs зайві keys).
Спосіб C. Лише query на статичний шлях
<Link href="/trips?filter=private">Приватні</Link>
Тут немає [id]; екран списку може прочитати filter і відфільтрувати data.
Що передавати: рядок, не «надію на об’єкт»
Params у URL — рядки (або те, що стане рядком). Не варто розраховувати, що через params «пролізе» складний об’єкт Trip. Правильний патерн Nomad:
- У маршрут кладете id.
- На екрані деталей дістаєте повний
TripзTripsProvider/ API за цим id.
Так deep link «відкрий поїздку 3» теж матиме сенс: ззовні прийде лише id, а дані підвантажаться з джерела правди.
?data=...JSON...) — крихко, довго, погано для логів і лімітів URL. Id + сховище/API надійніше.Як прочитати параметри: useLocalSearchParams
Базовий виклик
На екрані app/(tabs)/trips/[id].tsx:
import { useLocalSearchParams } from 'expo-router';
export default function TripDetailsScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
// …
}
Хук повертає об’єкт «ключі → значення з поточного маршруту» (і path, і query — у змішаному вигляді search params).
Дженерик <{ id: string }> допомагає TypeScript; це не runtime-валідація. Якщо id немає, у рантаймі буде undefined.
Чому string | string[] і нормалізація
На практиці значення інколи приходить масивом рядків (особливо з query або крайових випадків парсингу). Тому в Nomad і в міні-проєктах курсу вже є звичка:
const { id } = useLocalSearchParams<{ id: string | string[] }>();
const tripId = Array.isArray(id) ? id[0] : id;
Далі працюєте з tripId: string | undefined.
Це не «перестраховка для галочки» — це захист від рідкісного, але неприємного id.join is not a function / порівнянь масиву з рядком.
Немає id або немає сутності в даних
Два різні провали:
- Немає параметра — шлях зібраний криво, опечатка в
href. - Параметр є, але
getTrip(id)повернув undefined — поїздку видалили, mock скинули pull-to-refresh, id з пуша застарів.
UI в обох випадках має не падати:
if (!tripId || !trip) {
return (
<View>
<Text>Поїздку не знайдено</Text>
<Button title="Назад" onPress={() => router.back()} />
</View>
);
}
У Nomad це вже зроблено на екрані деталей — для deep link ззовні той самий UI «не знайдено» лишається обов’язковим (id з посилання може бути застарілим або хибним).
Local vs Global (коротко; деталі — за потреби пізніше)
Expo Router дає два схожі хуки:
| Хук | Ідея |
|---|---|
useLocalSearchParams | Params цього екрана / локального стану маршруту. Типовий вибір для [id].tsx. |
useGlobalSearchParams | Params, ближчі до «глобального» URL; оновлюються інакше при навігації по стеку (сусідні екрани в історії). |
Для екрана деталей поїздки починайте з local. Global знадобиться, коли свідомо підписуєтесь на зміну URL «вище» по дереву або дебажите розбіжності — не як перший інструмент.
Повний міні-цикл на прикладі (без deep link)
Покроково те, що вже має відбуватись у Nomad при тапі по картці.
1. Користувач на списку
Маршрут на кшталт /trips. Дані — масив trips із Context.
2. Тап по картці
Код: router.push(\/trips/${trip.id}`)(або Link з тим самим href). У шлях потрапляє **конкретний** id, наприклад1`.
3. Router обирає файл
Зіставляє path із app/(tabs)/trips/[id].tsx. Сегмент 1 → param id.
4. Монтується екран деталей
useLocalSearchParams → id. Нормалізація → tripId.getTrip(tripId) → об’єкт поїздки або undefined.
5. Рендер
Є trip — title, опис, місця. Немає — «не знайдено» + back.
6. Back
router.back() у вкладеному Stack → знову список. Param id для списку не потрібен.
Deep link «ззовні» пізніше вклиниться між кроками 0 і 3: ОС відкриє застосунок уже з path /trips/1, і кроки 3–5 виконаються без тапу по картці. Саме тому id у path такий важливий: зовнішнє посилання не може «передати» React-об’єкт, лише адресу.
Типові помилки з params
- Опечатка в сегменті —
push('/trip/3')при файліtrips/[id].tsx→ route not found / wrong screen. - Забули нормалізувати масив — рідкісні креші при порівнянні id.
- Чекають об’єкт у params — приходить рядок; об’єкт треба дістати зі store.
- Немає UI на missing — білий екран або crash при
.titleвід undefined. - Плутають query і path — фільтр у динамічному сегменті («файл на кожен filter») замість
?filter=. - Жорстко прошиті id в Link — у демо ок; у списку завжди з
item.id.
Коротка практика
- У Nomad (локально) тимчасово залогуйте
useLocalSearchParams()на екрані деталей і відкрийте дві різні поїздки — порівняйте об’єкт params. - Додайте в push query
?from=listі виведітьfromпід заголовком деталей (потім приберіть). - Відкрийте неіснуючий id (
/trips/no-such) через тимчасову кнопку — переконайтесь, що бачите «не знайдено», а не crash.
Deep linking: відкриття застосунку ззовні
Досі ми розглядали шлях всередині вже відкритого застосунку: тап → router.push → params. Deep linking (глибоке посилання) — ситуація, коли адреса приходить ззовні: з браузера, нотаток, QR-коду, пуш-сповіщення, іншого застосунку. ОС дивиться на URL, знаходить ваш застосунок (якщо він зареєстрований на цей тип посилань) і передає URL у нього. Далі Expo Router зіставляє path із файлами в app/ — і ви опиняєтесь на тому ж екрані деталей, що після тапу по картці.
Саме тому params у path були першими: зовнішнє посилання не може передати React-об’єкт Trip. Воно передає лише рядок на кшталт nomad://trips/3. Усе інше — ваш код: прочитати id, знайти дані в store / API, намалювати UI або «не знайдено».
onPress у списку, а ОС із URL. Якщо всередині застосунку /trips/3 уже відкриває деталі, deep link із тим самим path має зробити те саме — за умови, що scheme/асоціація налаштовані.Custom URL scheme
Що це таке
Custom URL scheme (власна схема URL) — префікс протоколу, який ваша програма реєструє в системі, наприклад nomad:// або myapp://.
Порівняння з вебом:
| Веб | Custom scheme | |
|---|---|---|
| Приклад | https://kostyl.dev/… | nomad://trips/3 |
| Хто «володіє» | DNS + HTTPS | пакет, встановлений на телефоні |
| Де працює | будь-який браузер | лише якщо встановлено застосунок із цією схемою |
Коли користувач (або інший застосунок) відкриває nomad://trips/3:
- ОС шукає, хто заявив scheme
nomad. - Запускає або «будить» цей застосунок.
- Передає повний URL у runtime.
- Expo Router / linking шар витягує path (
trips/3→ маршрут/trips/3) і відкриває відповідний екран.
Де задається в Expo
У app.json (або app.config.*) поле scheme:
{
"expo": {
"scheme": "nomad"
}
}
У репозиторії Nomad це вже є: "scheme": "nomad". Тобто цільові посилання виглядатимуть як:
nomad://trips/1
nomad://trips/local-1710000000
nomad://create-trip
Після зміни scheme у вже зібраному нативному бінарнику інколи потрібен новий dev build / перезбірка (Expo Go має власні правила — див. нижче). Для чистого JS-оновлення в Expo Go scheme проєкту підхоплюється в dev-сесії, але поведінка відрізняється від production-збірки з «вашим» іменем у магазині.
://. Лише латиниця, цифри, дефіс — у стилі nomad, my-app. Не плутати зі slug ("slug": "nomad" — id проєкту Expo) і name (відображувана назва).myapp://, поведінка ОС неоднозначна. Для навчального й продакшен-імені обирайте унікальніший префікс (бренд, суфікс).Анатомія URL зі scheme
Розберемо рядок:
nomad://trips/3?tab=places
| Шматок | Значення |
|---|---|
nomad | scheme (хто відкриває) |
trips/3 | path → маршрут Router (часто нормалізують до /trips/3) |
tab=places | query → params на екрані |
Іноді зустрічається host:
nomad://expo-development-client/?url=…
У production custom scheme для ваших екранів зазвичай без зайвого host або з домовленістю, яку очікує Router. У розробці Expo підставляє складніші URL (Expo Go / dev client) — тому для генерації посилань краще не хардкодити nomad://… вручну скрізь, а знати про Linking.createURL (нижче).
Пакет expo-linking
expo-linking — модуль Expo для роботи з URL: зібрати посилання на свій застосунок, розпарсити URL, відкрити зовнішнє посилання, підписатись на вхідні URL.
У Nomad пакет уже в залежностях (expo-linking у package.json) — його тягне екосистема Router.
Linking.createURL(path, options?)
Будує коректний URL для поточного середовища:
- у production-збірці з
"scheme": "nomad"— щось на кшталтnomad:///trips/3(точний вигляд залежить від платформи/версії); - у Expo Go — URL виду
exp://…із шляхом, бо Go відкриває проєкти інакше.
import * as Linking from 'expo-linking';
const url = Linking.createURL('/trips/3');
// зручно залогувати або показати в UI «поділитись»
console.log(url);
З query:
const url = Linking.createURL('/trips/3', {
queryParams: { tab: 'places' },
});
nomad://trips/3 у всьому коді «для шерингу», якщо той самий код крутиться в Expo Go і в standalone. createURL підлаштовує base під середовище. Для документації й тестів на пристрої з установленим білдом nomad://… — нормально.Linking.parse(url)
Розбирає рядок URL на складові (scheme, path, queryParams тощо) — корисно в логах і дебазі:
import * as Linking from 'expo-linking';
const parsed = Linking.parse('nomad://trips/3?tab=places');
// path / hostname / queryParams — дивіться об’єкт у консолі
Linking.openURL(url)
Відкриває URL системно: браузер, інший застосунок, інколи знову ваш (якщо scheme ваш).
await Linking.openURL('https://kostyl.dev');
// або
await Linking.openURL(Linking.createURL('/trips/1'));
Перед відкриттям інколи перевіряють Linking.canOpenURL (на iOS для чужих scheme можуть знадобитись записи в Info.plist / config plugins — для відкриття свого scheme зсередини зазвичай простіше).
Linking.useURL() / події
Хук useURL() повертає URL, з яким застосунок відкрили (або null). Корисно для діагностики: «з яким рядком мене зараз відкрили?».
Expo Router сам підписується на вхідні URL і навигує на відповідний маршрут, якщо path збігається з app/. Вам рідко треба вручну парсити кожен deep link і викликати router.push — але розуміти шар linking потрібно, коли щось «не відкривається» або path приходить у несподіваному вигляді.
/ для маршрутів Router: '/trips/3'.Як перевірити scheme на практиці
1. Переконайтесь, що scheme у конфігу
Nomad: "scheme": "nomad" у app.json. Після зміни — перезапуск / новий білд за потреби.
2. Expo Go vs dev client / standalone
| Середовище | Що очікувати |
|---|---|
| Expo Go | Deep link часто йде через exp:// і UI Go; custom scheme проєкту працює обмежено / інакше, ніж у магазині. Для навчання params всередині app — Go достатньо; для «як у проді nomad://» надійніше development build або preview build. |
| Dev client / preview / store | Custom scheme з app.json реєструється в нативному проєкті; nomad://trips/3 має відкривати застосунок. |
Курс не вимагає одразу EAS build для кожної вправи, але варто знати: «у Go не відкрилось nomad://» ≠ «Router зламаний».
3. Команди й системні способи тесту
iOS Simulator (якщо білд установлений):
xcrun simctl openurl booted "nomad://trips/1"
Android (емулятор / девайс з adb, пакет установлений):
adb shell am start -a android.intent.action.VIEW -d "nomad://trips/1"
У застосунку: кнопка «Відкрити тестове посилання»:
import * as Linking from 'expo-linking';
await Linking.openURL(Linking.createURL('/trips/1'));
Нотатки / Safari: вставити nomad://trips/1 (на реальному пристрої з установленим білдом). Поведінка Safari з custom scheme різниться; інколи зручніше Notes або QR.
4. Що має статись у Nomad
Якщо Router і вкладений стек налаштовані (ст. 11–12):
- Відкривається застосунок (або виходить на передній план).
- Навігація веде на екран
trips/[id]зid=1. - Якщо поїздка є в
TripsProvider— деталі; якщо ні — «не знайдено».
Перевіряйте обидва стани: валідний id з mock і свідомо невалідний (nomad://trips/nope).
Cold start і warm start (на рівні ідеї)
Коли deep link відкриває застосунок, важливий стан процесу.
Cold start (холодний старт) — застосунок не крутився в пам’яті (або був убитий системою). ОС запускає процес з нуля: JS-бандл, providers, Router, потім (або разом) обробка URL. Час до першого кадру довший; порядок ініціалізації критичний (провайдери мають існувати, перш ніж екран деталей читає getTrip).
Warm start (теплий) — застосунок уже був у фоні. ОС лише передає новий URL; Router навигує на маршрут. Стан у пам’яті (наприклад, уже завантажені trips) може зберегтись.
/trips/3 має спрацювати після того, як root layout змонтував TripsProvider. Якщо дані лише «випадково» були в state з попередньої сесії — на cold start їх не буде: потрібні mock seed, persist (MMKV — пізніше) або fetch.(tabs) допомагає мати базу історії, коли вхід одразу на глибокий екран.Детальний порядок «що першим: splash, auth, url» у великих застосунках розростається. На рівні курсу достатньо: cold start = даних у RAM може не бути; path у URL — єдине, що точно прийшло ззовні.
Зв’язок scheme і params (зшивка)
Повний шлях даних для nomad://trips/3:
1. Зовнішній світ
Користувач відкриває посилання (Notes, QR, adb, інший app).
2. ОС
Бачить scheme nomad → знаходить пакет Nomad → стартує / resume.
3. Linking + Expo Router
URL → path /trips/3 (нормалізація) → файл app/(tabs)/trips/[id].tsx.
4. Params
useLocalSearchParams → id → tripId = "3".
5. Дані
getTrip("3") з Context / API. Не з URL (у URL лише id).
6. UI
Деталі або «не знайдено». Back / tabs — як у звичайній внутрішній навігації.
Якщо крок 5 падає на cold start (порожній store) — це вже архітектура даних, не «Router не вміє deep link». Mock у TripsProvider при старті якраз рятує демо; у проді з’являться persist і мережа.
Типові помилки з scheme і linking
- Забули
schemeу app.json — custom URL «нічий». - Тестують
nomad://лише в Expo Go і роблять висновок, що все зламано. - Хардкод
nomad://у коді, який крутиться в Go — кращеcreateURLдля шерингу з того ж білда. - Path без узгодження з
app/—nomad://trip/3при файліtrips/[id]. - Очікують об’єкт Trip у URL — ззовні лише рядки.
- Не перевіряють missing entity на cold start.
- Колізія scheme з іншим навчальним додатком на тому ж симуляторі.
Практика
- Виведіть на екран «Ще» у Nomad результат
Linking.createURL('/trips/1')(тимчасовийAppText) — порівняйте рядок у Go і, якщо є, у standalone. - З симулятора / adb надішліть
nomad://trips/1на білд, де scheme зареєстровано. - Надішліть
nomad://trips/does-not-exist— має бути спокійний empty/error UI. - Відкрийте deep link, коли застосунок уже на екрані форми create-trip (warm) — подивіться, чи прийнятна поведінка.
Universal Links і Android App Links
Custom scheme (nomad://…) зручний для розробки й внутрішніх сценаріїв. Для користувача в месенджері чи пошті частіше хочуть звичайне посилання:
https://app.example.com/trips/3
Якщо застосунок установлений і домен прив’язаний до нього, система може відкрити застосунок, а не (або не лише) браузер. Якщо застосунку немає — відкривається сайт (fallback). Це і є ідея Universal Links (iOS) та App Links (Android).
Навіщо ускладнення
Custom scheme nomad:// | https://… (universal / app links) |
|---|---|
| Працює без домену | Потрібен ваш домен і файли на ньому |
| Якщо app немає — часто «нічого» / помилка | Якщо app немає — веб-сторінка |
| У чатах інколи виглядає «підозріло» | Звичайне https-посилання |
| Швидко для dev | Потрібна верифікація ОС (AASA / assetlinks) |
| Колізії scheme між apps | Прив’язка до bundle id + сертифікатів |
У проді обидва підходи часто співіснують: https для людей, scheme для внутрішніх інструментів і legacy.
Як це влаштовано (ідея, не чекліст магазину)
Система не вірить застосунку на слово «я відкриваю example.com». Потрібна двостороння асоціація:
- На сайті лежить файл, який каже: «ці path відкриває app з таким id».
- У застосунку (нативна конфігурація) заявлено: «я обслуговую цей домен».
1. iOS — Universal Links
- У
app.json/ config:ios.associatedDomains, наприкладapplinks:app.example.com(безhttps://у цьому рядку — поширена помилка). - На сервері: файл Apple App Site Association (AASA), зазвичай
https://app.example.com/.well-known/apple-app-site-association
(без зайвого.jsonу проді Apple, content-type як у docs). - Усередині —
appID= TeamID + bundle identifier і списокpaths(/trips/*тощо). - Збірка з entitlements Associated Domains (EAS Build підхоплює з config).
2. Android — App Links
- У config:
android.intentFiltersзhttps,host,pathPrefix, частоautoVerify: true. - На сервері:
https://app.example.com/.well-known/assetlinks.json
зpackage_nameі SHA-256 відбитками підпису (debug і release — різні!). - Система верифікує файл; після успіху https-лінки можуть відкривати app без діалогу «чим відкрити».
3. Expo / EAS
Конфіг у app.json / app.config.js — джерело для prebuild. Самі AASA / assetlinks не з’являються «самі» на домені: їх викладає бекенд / static hosting / CDN. Для курсу достатньо розуміти ланцюг; повний setup з реальним доменом — окремий інфраструктурний крок продукту.
Приклад фрагментів config (орієнтир)
iOS associated domain:
{
"expo": {
"ios": {
"bundleIdentifier": "com.example.nomad",
"associatedDomains": ["applinks:app.example.com"]
}
}
}
Android intent filter (схема):
{
"expo": {
"android": {
"package": "com.example.nomad",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "app.example.com",
"pathPrefix": "/trips"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
Це не копіювати сліпо в Nomad без реального домену — інакше білд лише «заявить» права, які ОС не підтвердить.
Що спільного з params
Після того, як ОС віддала застосунку URL:
https://app.example.com/trips/3
Router знову бачить path /trips/3, [id] → 3, useLocalSearchParams — як після nomad://trips/3 або внутрішнього push. Екран і params ті самі; відрізняється лише транспорт доставки URL.
/trips/3 на вебі має існувати або редіректити розумно — інакше «мертве» посилання. Це продуктове рішення (маркетинг + app), не лише RN.Коли в курсі що використовувати
| Задача | Рекомендація курсу |
|---|---|
| Навчання params і Router | внутрішній push + scheme у dev |
| Демо deep link на симуляторі | nomad://… + dev/preview build |
| Прод «поділитись у Telegram» | https + universal/app links (коли є домен) |
| Expo Go | не критерій «прод deep link» |
Міні-проєкт: «Купон за лінком» (від А до Я)
Мета
Окремий застосунок поза 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
Вкладені навігатори та модалки
Stack усередині Tabs, presentation modal, групи auth, guard на back — нотатки з модалкою і деталі поїздки в Nomad
Робота з мережею та життєвий цикл застосунку
Глибоке розуміння fetch та axios у React Native, обробка помилок мережі, відстеження стану мережі (NetInfo) та стану застосунку (AppState)