Робота з мережею та життєвий цикл застосунку
Робота з мережею та життєвий цикл застосунку
Від вебу до мобільного пристрою: чому мережа в React Native працює інакше
У веб-розробці ми звикли до відносно стабільного середовища. Користувач відкриває сайт у браузері на ноутбуці чи комп'ютері, де майже завжди є стабільний Ethernet або Wi-Fi. Якщо інтернет зникає, браузер сам покаже стандартну сторінку помилки з «динозавриком», а розробник у коді React рідко переймається тим, що запит fetch('/api/users') раптово обірветься на пів дорозі через вхід у ліфт чи тунель метро.
У мобільних застосунках мережеве середовище агресивне й нестабільне. Телефон постійно рухається у просторі:
- Перемикання базових станцій та мереж — пристрій переходить з Wi-Fi кав'ярні на LTE/5G мобільного оператора. У цей момент змінюється IP-адреса пристрою, а відкриті TCP-з'єднання обриваються.
- Зони з нульовим покриттям («мертві зони») — ліфти, підземні паркінги, тунелі метро, заміські траси. Запит виходить з пристрою, але відповідь не повертається.
- Обмеження ресурсомісткості ОС — операційна система (iOS чи Android) стежить за зарядом батареї. Якщо ваш застосунок згортають у фоновий режим під час виконання запиту, ОС може примусово «приспати» мережевий socket або взагалі завершити процес.
Крім того, існує фундаментальна різниця у тому, хто саме виконує мережевий запит у мобільному застосунку.
Місток від Web React до React Native: що відбувається під капотом
У браузері запит виконує двигун браузера (Chrome V8, Firefox Gecko, Safari WebKit), який суворо дотримується політики безпеки CORS (Cross-Origin Resource Sharing). Якщо сервер не надіслав правильні заголовки Access-Control-Allow-Origin, браузер заблокує відповідь.
У React Native ситуація інша. Хоча ви пишете звичайний JS-код:
fetch('https://api.nomad.app/v1/trips');
JavaScript-рушій Hermes не виконує мережеві запити сам. Він передає цей намір нативному шару операційної системи.
- На iOS запит виконується через нативний системний модуль
NSURLSession. - На Android запит виконується через нативну бібліотеку
OkHttpClient(або системнийHttpURLConnection).
Анатомія HTTP-запитів: fetch vs axios у мобільному середовищі
Оскільки глобальна функція fetch доступна в React Native «з коробки», багато новачків починають саме з неї. Проте в мобільній розробці є критичні нюанси, які відрізняють fetch від сторонніх бібліотек на кшталт axios.
Глобальний fetch у React Native та його пастки
React Native надає поліфіл fetch, який відтворює стандартний браузерний W3C Fetch API. Проте в мобільному середовищі у fetch є дві небезпечні особливості:
- Відсутність таймауту за замовчуванням. Якщо пристрій потрапить у зону слабкого покриття, де пакети втрачаються (packet loss),
fetchчекатиме відповіді нескінченно довго. Запит «висить», з'єднання не закривається, пам'ять та акумулятор виснажуються, а користувач бачить вічний індикатор завантаження. fetchне вважає статус 4xx або 5xx помилкою. Якщо сервер відповів500 Internal Server Errorабо404 Not Found, промісfetchуспішно виконається (resolved), і вам потрібно вручну перевіряти властивістьresponse.ok.
Щоб вирішити проблему з таймаутом у fetch, використовують контролер скасування AbortController:
async function fetchTripsWithTimeout(timeoutMs = 8000) {
const controller = new AbortController();
// Встановлюємо таймер примусового скасування
const timerId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch('https://api.nomad.app/v1/trips', {
signal: controller.signal,
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error: any) {
if (error.name === 'AbortError') {
throw new Error('Мережевий запит перевищив ліміт часу (Timeout)');
}
throw error;
} finally {
clearTimeout(timerId);
}
}
Чому в реальних мобільних проєктах часто обирають axios
Бібліотека axios вирішує більшість рутинних задач мобільної мережі «із коробки»:
- Вбудовані таймаути: достатньо вказати
timeout: 10000у мілісекундах. - Автоматична трансформація JSON: не потрібно викликати
.json()вручну. - Перехоплювачі (Interceptors): дозволяють підставляти токени автентифікації в кожен запит та централізовано обробляти помилки (наприклад, оновлювати expired-токен при 401).
- Автоматична автоматика викидання помилок: будь-який HTTP-код за межами 2xx одразу потрапляє в блок
catch.
Порівняємо обидва підходи:
import axios from 'axios';
// Створюємо налаштований екземпляр для нашого API
export const apiClient = axios.create({
baseURL: 'https://api.nomad.app/v1',
timeout: 10000, // 10 секунд таймаут
headers: {
'Content-Type': 'application/json',
},
});
// Додаємо інтерцептор для токена
apiClient.interceptors.request.use((config) => {
const token = 'user-auth-token-placeholder'; // Отримаємо з SecureStore
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Використання
async function getTrips() {
try {
const response = await apiClient.get('/trips');
return response.data; // Автоматично розпарсений JSON
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNABORTED') {
console.error('Таймаут з'єднання з сервером');
} else {
console.error(`Помилка сервера: ${error.response?.status}`);
}
}
throw error;
}
}
// Ручна обробка заголовків, таймауту та статусів
async function getTripsFetch() {
const controller = new AbortController();
const timerId = setTimeout(() => controller.abort(), 10000);
try {
const token = 'user-auth-token-placeholder';
const response = await fetch('https://api.nomad.app/v1/trips', {
method: 'GET',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`HTTP Status ${response.status}`);
}
return await response.json();
} catch (error: any) {
if (error.name === 'AbortError') {
console.error('Таймаут запиту');
}
throw error;
} finally {
clearTimeout(timerId);
}
}
Анатомія конфігурації мережевого запиту
При побудові мережевого шару мобільного застосунку звертайте увагу на наступні параметри:
https://api.nomad.app/v1). Дозволяє не дублювати доменне ім'я в кожному запиті.Accept: application/json, а також корисні кастомні заголовки: User-Agent (наприклад, Nomad/1.0 (iOS 17.2)), X-App-Version, X-Platform (ios або android).unmount компонента. Якщо користувач відкрив екран деталей поїздки, за чекання в 10 секунд натиснув «Назад», а запит повернувся пізніше і спробував оновити стан розмонтованого екрана — у старіших версіях React ви отримаєте memory leak warning, а у застосунку виникне марне споживання трафіку та акумулятора.Таксономія помилок мережі (Error Taxonomy) та стратегії повторних спроб
У мобільних застосунках найгірше, що можна зробити при виникненні помилки мережі — це показати користувачу технічний екран з текстом AxiosError: Network Error або незрозумілий alert(error.message).
Щоб побудувати надійну систему обробки помилок, їх потрібно розділити на чотири чіткі рівні (таксономія). Кожен рівень вимагає власної стратегії інформування користувача та повторних спроб.
4 рівні помилок та реакція застосунку
500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable. Сервер перезавантажується або не впорається з навантаженням.
Що бачити користувач: «Сервер тимчасово недоступний. Ми вже лагодимо це».
Реакція системи:Exponential Backoff із Jitter — повторна спроба через 1с, потім 2с, 4с, 8с з додаванням рандомної затримки. Це запобігає «шторму запитів» (Thundering Herd Problem), коли тисячі мобільних пристроїв одночасно намагаються перепідключитися до відновленого сервера.401 Unauthorized (застарілий access-токен), 403 Forbidden (немає прав), 422 Unprocessable Entity (помилка валідації форми).
Що бачити користувач: підказки біля полів форми або перенаправлення на екран входу.
Реакція системи: при 401 — спробувати фоново оновити токен через Refresh Token. Якщо не вдалося — очистити сесію та перенаправити на Auth-екран. Ніколи не робити автоматичні retry для помилок 4xx, оскільки повторний запит з тими самими даними дасть той самий негативний результат!Створення уніфікованого нормалізатора помилок (AppError)
Щоб не обробляти Axios-помилки розкидано по всьому коду, створюють єдиний клас-нормалізатор:
export enum ErrorKind {
Network = 'NETWORK',
Timeout = 'TIMEOUT',
Server = 'SERVER',
Unauthorized = 'UNAUTHORIZED',
NotFound = 'NOT_FOUND',
Validation = 'VALIDATION',
Unknown = 'UNKNOWN',
}
export class AppError extends Error {
constructor(
public readonly kind: ErrorKind,
public readonly userMessage: string,
public readonly statusCode?: number,
public readonly originalError?: unknown
) {
super(userMessage);
this.name = 'AppError';
}
static from(error: unknown): AppError {
if (error instanceof AppError) return error;
// Якщо це помилка Axios
if (typeof error === 'object' && error !== null && 'isAxiosError' in error) {
const axiosErr = error as any;
if (axiosErr.code === 'ECONNABORTED') {
return new AppError(
ErrorKind.Timeout,
'Запит тривав занадто довго. Перевірте з'єднання.',
undefined,
error
);
}
if (!axiosErr.response) {
return new AppError(
ErrorKind.Network,
'Відсутнє з'єднання з інтернетом або сервером.',
undefined,
error
);
}
const status = axiosErr.response.status;
if (status === 401) {
return new AppError(ErrorKind.Unauthorized, 'Сесія вичерпана. Увійдіть знову.', status, error);
}
if (status === 404) {
return new AppError(ErrorKind.NotFound, 'Запитуваний ресурс не знайдено.', status, error);
}
if (status >= 500) {
return new AppError(ErrorKind.Server, 'На сервері сталася помилка. Спробуйте пізніше.', status, error);
}
}
return new AppError(ErrorKind.Unknown, 'Сталася несподівана помилка.', undefined, error);
}
}
AppError дозволяє розділити технічні деталі (які відправляються в логи/Sentry) від повідомлень для UI, які бачить звичайний користувач у банерах чи модальних вікнах.Відстеження стану мережі у реальному часі: @react-native-community/netinfo
Дізнатися про відсутність інтернету до того, як користувач натисне кнопку і чекатиме 10 секунд таймауту — це ознака якісного мобільного застосунку.
Оскільки в самому core-пакеті React Native модуля мережевого стану немає (його винесли в процесі Lean Core), стандартним рішенням у спільноті є пакет @react-native-community/netinfo.
Установка в Expo
npx expo install @react-native-community/netinfo
Плутанина двох прапорців: isConnected vs isInternetReachable
Коли ви запитуєте стан мережі у NetInfo, об'єкт стану повертає дві ключові boolean-властивості:
true означає лише те, що телефон має мережевий зв'язок з локальним пристроєм/роутером. Це не гарантує, що через цей роутер є вихід у глобальний інтернет!null— стан ще перевіряється (при старті);false— Wi-Fi є (наприклад, у готелі з Captive Portal сторінкою входу або в метро), але інтернет не доступний;true— інтернет повністю функціонує.
if (state.isConnected) — це найпоширеніша помилка. Якщо користувач підключився до Wi-Fi у потягу, який їде лісом (Wi-Fi роутер у вагоні працює, але супутникового/мобільного зв'язку у роутера немає), isConnected буде true, але реальні запити падатимуть. Завжди орієнтуйтесь на isInternetReachable === true або сукупність двох прапорців!Кастомний React-хук: useNetworkStatus
Створимо зручний хук для використання у будь-якому компоненті:
import { useEffect, useState } from 'react';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
export interface NetworkStatus {
isConnected: boolean;
isInternetReachable: boolean;
isOffline: boolean;
type: string;
}
export function useNetworkStatus(): NetworkStatus {
const [status, setStatus] = useState<NetworkStatus>({
isConnected: true,
isInternetReachable: true,
isOffline: false,
type: 'unknown',
});
useEffect(() => {
// Передплата на зміни стану мережі в реальному часі
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
const isConnected = state.isConnected ?? false;
const isInternetReachable = state.isInternetReachable ?? false;
// Пристрій вважається офлайн, якщо немає з'єднання АБО інтернет недосяжний
const isOffline = !isConnected || !isInternetReachable;
setStatus({
isConnected,
isInternetReachable,
isOffline,
type: state.type,
});
});
return () => unsubscribe();
}, []);
return status;
}
Приклад UI: Індикатор відсутності мережі (Offline Banner)
Використовуючи хук useNetworkStatus, ми можемо показати ненав'язливий плаваючий плашка-банер у шапці застосунку:
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useNetworkStatus } from './useNetworkStatus';
export const OfflineBanner: React.FC = () => {
const { isOffline } = useNetworkStatus();
if (!isOffline) return null;
return (
<View style={styles.banner}>
<Text style={styles.text}>
Немає підключення до інтернету. Робота в офлайн-режимі.
</Text>
</View>
);
};
const styles = StyleSheet.create({
banner: {
backgroundColor: '#D32F2F', // Червоний застережливий колір
paddingVertical: 8,
paddingHorizontal: 16,
alignItems: 'center',
justifyContent: 'center',
},
text: {
color: '#FFFFFF',
fontSize: 13,
fontWeight: '600',
},
});
Життєвий цикл мобільного застосунку: AppState
На відміну від веб-сторінки, де користувач або відкрив вкладку, або закрив її, мобільний застосунок живе у складі складної операційної системи з жорстким керуванням пам'яттю та ресурсами.
Коли користувач натискає кнопку Home, перемикається на Telegram чи йому надходить вхідний дзвінок — ваш застосунок не закривається повністю, а змінює свій стан життєвого циклу (AppState).
Три стани AppState
Операційні системи iOS та Android визначають три основні стани застосунку:
- Увімкнення iOS App Switcher (панель перемикання між застосунками);
- Вхідний телефонний дзвінок або будильник;
- Системне вікно запиту дозволу (наприклад, «Надати доступ до геопозиції?»);
- Опускання верхньої шторки сповіщень.
Навіщо розробнику стежити за AppState
Робота з AppState необхідна для трьох критичних сценаріїв:
- Автоматичне поновлення застарілих даних (Refetch on App Active):
Якщо користувач відкрив список поїздок о 09:00, згорнув застосунок, а повернувся о 14:00 — дані на екрані застаріли. При переході з
backgroundуactiveпотрібно автоматично підтягнути нові дані з API. - Пауза таймерів, фонової анімації та polling-запитів:
Якщо у вас працює
setInterval, який кожні 5 секунд запитує нові повідомлення з сервера — його обов'язково потрібно призупиняти при переході вbackground. Інакше при поверненні в активний стан ви отримаєте пачку накопичених запитів, або акумулятор пристрою буде прискорено розряджатися. - Безпека та приватність (Blur overlay):
Банківські додаткові або застосунки з приватними нотатками у стані
inactive/backgroundнакривають екран розмитим фоном чи логотипом, щоб у панелі відкритих застосунків (App Switcher) випадковий перехожий не побачив баланс рахунку або особисті фото.
Кастомний React-хук: useAppState
Модуль AppState входить до складу ядра React Native. Напишемо зручний хук для відстеження змін стану:
import { useEffect, useState } from 'react';
import { AppState, AppStateStatus } from 'react-native';
export function useAppState(): AppStateStatus {
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState);
useEffect(() => {
// Передплата на зміну стану життєвого циклу
const subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
setAppState(nextAppState);
});
return () => {
subscription.remove();
};
}, []);
return appState;
}
Поєднуємо життєвий цикл та мережу: Сценарії автоматичного поновлення даних
Тепер поєднаємо знання про мережу та життєвий цикл застосунку.
У реальному мобільному додатку користувач очікує, що дані на екрані завжди свіжі, без необхідності постійно тиснути кнопку «Оновити».
Створимо комплексний хук useRefetchOnFocusAndNetwork, який trigger-ить оновлення даних у двох випадках:
- Користувач повернувся в застосунок із фону (
background➔active). - Пристрій відновив інтернет-з'єднання після офлайну (
isOffline: true➔isOffline: false).
import { useEffect, useRef } from 'react';
import { AppStateStatus } from 'react-native';
import { useAppState } from './useAppState';
import { useNetworkStatus } from './useNetworkStatus';
export function useRefetchOnFocusAndNetwork(onRefetch: () => void) {
const appState = useAppState();
const { isOffline } = useNetworkStatus();
// Зберігаємо попередні значення у ref, щоб порівнювати їх без зайвих re-renders
const prevAppStateRef = useRef<AppStateStatus>(appState);
const prevOfflineRef = useRef<boolean>(isOffline);
useEffect(() => {
// 1. Сценарій повернення з фону у активний стан
const isReturningToActive =
prevAppStateRef.current.match(/inactive|background/) && appState === 'active';
// 2. Сценарій відновлення мережевого підключення
const isReconnectingToNetwork = prevOfflineRef.current === true && isOffline === false;
if ((isReturningToActive || isReconnectingToNetwork) && !isOffline) {
console.log('Поновлення даних: новий фокус або відновлення мережі');
onRefetch();
}
// Оновлюємо refs
prevAppStateRef.current = appState;
prevOfflineRef.current = isOffline;
}, [appState, isOffline, onRefetch]);
}
setupListeners з NetInfo та AppState. Проте розуміння того, як це працює «під капотом» через нативні події, критично важливе для написання власного мережевого шару або кастомних ефектів.Міні-проєкт: «Курси валют»
У цьому міні-проєкті ми побудуємо самодостатній застосунок для моніторингу курсів валют, який об'єднує всі вивчені концепції мережі та життєвого циклу:
- Отримання даних з публічного API з таймаутом та скасуванням запиту;
- Індикатори завантаження (
ActivityIndicator), помилки з логікою повторних спроб (Retry); - Автоматичний індикатор відсутності зв'язку (Offline Banner via
NetInfo); - Автоматичне поновлення даних при поверненні в застосунок з фону (
AppState).
Структура проєкту
Кроки реалізації
Крок 1. Налаштування мережевого клієнта
Створюємо мережевий клієнт з обмеженням таймауту у 8 секунд для публічного API Національного банку України (або будь-якого REST API курсів валют):
// src/api/client.ts
import axios from 'axios';
export const currencyApi = axios.create({
baseURL: 'https://bank.gov.ua/NBUStatService/v1/statdirectory',
timeout: 8000, // 8 секунд таймаут
headers: {
'Accept': 'application/json',
},
});
Крок 2. Побудова компонента обробки помилок (ErrorView)
Якщо запит завершився з помилкою (наприклад, таймаут або 500 статус), показуємо дружній інтерфейс із можливістю повторити запит вручну:
// src/components/ErrorView.tsx
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
interface ErrorViewProps {
message: string;
onRetry: () => void;
}
export const ErrorView: React.FC<ErrorViewProps> = ({ message, onRetry }) => (
<View style={styles.container}>
<Text style={styles.title}>Помилка завантаження</Text>
<Text style={styles.message}>{message}</Text>
<Pressable style={styles.button} onPress={onRetry}>
<Text style={styles.buttonText}>Повторити спробу</Text>
</Pressable>
</View>
);
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
title: { fontSize: 18, fontWeight: '700', color: '#111827', marginBottom: 8 },
message: { fontSize: 14, color: '#6B7280', textAlign: 'center', marginBottom: 16 },
button: { backgroundColor: '#2563EB', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 8 },
buttonText: { color: '#FFFFFF', fontWeight: '600', fontSize: 15 },
});
Крок 3. Складання головного екрана зі списком валют
Об'єднуємо роботу з мережею, ActivityIndicator, FlatList та хук useRefetchOnFocusAndNetwork.
import React, { useCallback, useEffect, useState } from 'react';
import {
SafeAreaView,
View,
Text,
FlatList,
ActivityIndicator,
StyleSheet,
StatusBar,
RefreshControl,
} from 'react-native';
import { currencyApi } from './src/api/client';
import { AppError } from './src/api/AppError';
import { OfflineBanner } from './src/components/OfflineBanner';
import { ErrorView } from './src/components/ErrorView';
import { useRefetchOnFocusAndNetwork } from './src/hooks/useRefetchOnFocusAndNetwork';
interface CurrencyRate {
r030: number;
txt: string;
rate: number;
cc: string;
exchangedate: string;
}
export default function App() {
const [rates, setRates] = useState<CurrencyRate[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [refreshing, setRefreshing] = useState<boolean>(false);
const [error, setError] = useState<AppError | null>(null);
const fetchRates = useCallback(async (isManualRefresh = false) => {
if (isManualRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const response = await currencyApi.get<CurrencyRate[]>('/exchange?json');
// Фільтруємо лише популярні валюти (USD, EUR, PLN, GBP)
const popular = response.data.filter((item) =>
['USD', 'EUR', 'PLN', 'GBP', 'CHF'].includes(item.cc)
);
setRates(popular);
} catch (err) {
setError(AppError.from(err));
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
// Первинне завантаження при старті
useEffect(() => {
fetchRates();
}, [fetchRates]);
// Автоматичне поновлення даних при згортанні/розгортанні та при відновленні інтернет-мережі
useRefetchOnFocusAndNetwork(() => fetchRates());
return (
<SafeAreaView style={styles.safeArea}>
<StatusBar barStyle="dark-content" backgroundColor="#FFFFFF" />
<OfflineBanner />
<View style={styles.header}>
<Text style={styles.headerTitle}>Курси валют НБУ</Text>
<Text style={styles.headerSubtitle}>Автопоновлення при поверненні в додаток</Text>
</View>
{loading && !refreshing ? (
<View style={styles.center}>
<ActivityIndicator size="large" color="#2563EB" />
<Text style={styles.loadingText}>Отримання актуальних курсів...</Text>
</View>
) : error ? (
<ErrorView message={error.userMessage} onRetry={() => fetchRates()} />
) : (
<FlatList
data={rates}
keyExtractor={(item) => item.cc}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => fetchRates(true)}
colors={['#2563EB']}
/>
}
renderItem={({ item }) => (
<View style={styles.card}>
<View>
<Text style={styles.currencyCode}>{item.cc}</Text>
<Text style={styles.currencyName}>{item.txt}</Text>
</View>
<Text style={styles.rateValue}>{item.rate.toFixed(2)} ₴</Text>
</View>
)}
contentContainerStyle={styles.listContent}
/>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1, backgroundColor: '#F9FAFB' },
header: { padding: 16, backgroundColor: '#FFFFFF', borderBottomWidth: 1, borderBottomColor: '#E5E7EB' },
headerTitle: { fontSize: 22, fontWeight: '800', color: '#111827' },
headerSubtitle: { fontSize: 13, color: '#6B7280', marginTop: 2 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
loadingText: { marginTop: 12, color: '#6B7280', fontSize: 14 },
listContent: { padding: 16 },
card: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#FFFFFF',
padding: 16,
borderRadius: 12,
marginBottom: 12,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
currencyCode: { fontSize: 18, fontWeight: '700', color: '#111827' },
currencyName: { fontSize: 13, color: '#6B7280', marginTop: 2 },
rateValue: { fontSize: 20, fontWeight: '700', color: '#059669' },
});
Практичний крок у Nomad
У наскрізному мобільному щоденнику подорожей Nomad ми підключаємо інтеграцію з мок-сервером або віддаленим API для завантаження списку поїздок користувача.
Навіщо це користувачу
Користувач бачить реальний список своїх подорожей, завантажений із сервера API. Якщо інтернет у дорозі зникає, додаток інформує про офлайн-стан, не падінням або білим екраном, а з можливістю переглядати раніше завантажені поїздки.
Поточний стан та нові зміни
- Уже є: Базова структура навігації, дизайн-токени, картки поїздок та списки.
- Додаємо в цій статті:
- Мережевий шар
src/api/(client.ts,tripsApi.ts,AppError.ts); - Локальний мок-сервер на базі
json-server(db.json+npm run api); - Завантаження списку поїздок через HTTP при старті (
TripsProvider+ GET/trips); - Індикатори стану: Loading, Error + Retry, Pull-to-Refresh;
- Хуки
useNetworkStatus,useAppState,useRefetchOnFocusAndNetwork; OfflineBannerу кореневому_layout.tsx.
- Мережевий шар
Локальне API на базі json-server
Для реалістичного розробницького середовища ми використовуємо json-server. Він дозволяє створити повноцінне REST API з підтримкою фільтрації, пагінації та затримок без написання серверного коду.
1. Створення файлу мок-даних db.json
У корені проєкту Nomad створюємо файл db.json:
Форма записів збігається з типом Trip у застосунку (щоб TripCard і деталі працювали без додаткового мапінгу). Скорочений приклад:
{
"trips": [
{
"id": "1",
"title": "Карпати на вихідні",
"dateLabel": "12–14 бер. 2026",
"description": "Говерла, полонини й вечірній чай біля каміну в котеджі.",
"coverUri": "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=800&q=80",
"region": "Карпати"
},
{
"id": "2",
"title": "Львівський вікенд",
"dateLabel": "2–4 трав. 2026",
"description": "Кава, площа Ринок, закапелки й нічний трамвай.",
"coverUri": "https://images.unsplash.com/photo-1555881400-74d7acaacd8b?w=800&q=80",
"region": "Захід"
}
]
}
Повний seed (12 поїздок) — у db.json репозиторію Nomad.
2. Запуск з прив'язкою до --host 0.0.0.0
--host 0.0.0.0: За замовчуванням json-server слухає лише 127.0.0.1. Мобільний пристрій або емулятор вважає 127.0.0.1власною адресою, тому спроба зробити запит до http://127.0.0.1:3000 з Android-емулятора або смартфона завершиться помилкою Network Error.Додаємо скрипт у package.json та запускаємо його:
"scripts": {
"api": "json-server --watch db.json --port 3000 --host 0.0.0.0"
}
3. Динамічне визначення baseURL для різних пристроїв
Оскільки iOS Симулятор, Android Емулятор та фізичний телефон через Expo Go підключаються до вашого комп'ютера по-різному, налаштовуємо мережевий клієнт у src/api/client.ts:
import { Platform } from 'react-native';
import Constants from 'expo-constants';
import axios from 'axios';
const getBaseUrl = (): string => {
// 1. Android Emulator використовує спеціальний псевдонім 10.0.2.2
if (Platform.OS === 'android') {
return 'http://10.0.2.2:3000';
}
// 2. Для реального смартфона (Expo Go) беремо IP-адресу ПК з конфігурації Expo
const hostUri = Constants.expoConfig?.hostUri;
if (hostUri) {
const ip = hostUri.split(':').shift();
return `http://${ip}:3000`;
}
// 3. iOS Simulator може звертатися через localhost
return 'http://localhost:3000';
};
export const apiClient = axios.create({
baseURL: getBaseUrl(),
timeout: 8000,
headers: {
'Content-Type': 'application/json',
},
});
npm run api та npx expo start, клієнт автоматично вибере потрібну IP-адресу залежно від того, де саме запущено застосунок (на симуляторі Mac, Android Studio чи на реальному пристрої через QR-код).Знімок файлової структури проєкту Nomad
Перевірка роботи
- Запустіть проєкт у Expo Go або емуляторі:
npx expo start. - При завантаженні списку поїздок відображається індикатор
ActivityIndicator. - Увімкніть Режим польоту (Airplane Mode) на телефоні або вимкніть Wi-Fi на емуляторі: зверху екрана миттєво з'явиться червоний банер «Немає підключення до інтернету».
- Згорніть додаток, увімкніть мережу та розгорніть додаток — список поїздок фоново поновиться без додаткових дій користувача.
Повідомлення коміту в репозиторії Nomad
git commit -m "$(cat <<'EOF'
feat: fetch trips from API
Material: content/15.react-native/14.networking-and-app-lifecycle.md
EOF
)"
Самостійні практичні завдання
| Рівень | Завдання |
|---|---|
| Basic | Додати у міні-проєкт «Курси валют» поле input для пошуку / фільтрації за назвою валюти. Реалізувати виклики fetchRates() через стандартну кнопку «Оновити». |
| Intermediate | Написати кастомний хук useFetch<T>(url: string), який приймає URL, створює AbortController, скасовує запит при размонтировании компонента (unmount) та повертає { data, loading, error, refetch }. |
| Pro | Реалізувати перехоплювач Axios (Interceptor) для оновлення застарілого токена авторизації (Refresh Token Flow). Якщо запит повертає 401 Unauthorized, перехоплювач повинен поставити наступні запити на чергу (queue), виконати один фоновий POST /auth/refresh, оновити токен у SecureStore та повторити зупинені запити. |
Поширені запитання (FAQ)
У мобільному емуляторі localhost або 127.0.0.1 посилається на сам емулятор/телефон, а не на ваш комп'ютер, де запущено сервер API!
- Для Android Emulator: використовуйте спеціальну IP-адресу
http://10.0.2.2:3000. - Для iOS Simulator: можна використовувати
http://localhost:3000або локальну IP-адресу комп'ютера в мережі Wi-Fi (наприкладhttp://192.168.1.50:3000). - Для фізичного телефону: телефон та комп'ютер повинні бути в одній Wi-Fi мережі, і в якості URL слід вказувати локальну IP-адресу вашого комп'ютера.
NSURLSession) та Android (OkHttpClient) роблять HTTP-запити напряму без перевірки заголовків CORS..then() або await виконається лише тоді, коли користувач поверне додаток в активний стан.Deep linking і параметри маршрутів
Параметри в Expo Router — динамічні сегменти, query, useLocalSearchParams; scheme, universal links і cold start
Вступ до штучного інтелекту та машинного навчання
Що таке AI, ML та Data Science, чим вони відрізняються, як комп'ютер навчається на даних — без формул, але з ясними аналогіями та хронологією розвитку від 1950 до ChatGPT