В React Native данные с сервера загружают тем же fetch, что и в браузере, но экран обязан пережить плохую сеть: у него есть состояния загрузки, ошибки, пустого списка и «данных из кэша». Настройки и кэш хранят в AsyncStorage — это незашифрованное хранилище строк, а токены и другие секреты — в expo-secure-store, который шифрует значения через Keychain на iOS и Keystore на Android. Когда запросов становится много, ручной код заменяют TanStack Query, а для больших связанных данных берут SQLite.
После материала вы сможете
- загрузить список через
fetchс проверкойresponse.ok, отменой черезAbortControllerи состояниями загрузки, ошибки и пустого списка - показать список в
FlatListс pull-to-refresh и не терять данные на экране при ошибке сети - сохранить настройки и кэш в AsyncStorage, а токен — в
expo-secure-store, и объяснить разницу - проверить логику хранения и состояния экрана тестами
jest-expoбез устройства
Что знать заранее
Проверено на
Expo SDK 57 (expo 57.0.27)React Native 0.86.3React 19.2.3TypeScript 6.0.3@react-native-async-storage/async-storage 2.2.0expo-secure-store 57.0.4@tanstack/react-query 5.104.2expo-network 57.0.2jest-expo 57.0.5 + Jest 29.7@testing-library/react-native 14.1.0Node.js 24 LTS (24.18.0)
Сверено с документацией: 11 октября 2026 г.
Содержание
Сеть на телефоне: пять состояний вместо двух
Мобильное приложение теряет связь в лифте и метро, переключается между Wi-Fi и мобильной сетью, а пользователь открывает его из фона спустя несколько часов. Поэтому экран, который просто ждёт ответа сервера и рисует список, в реальности почти всегда ломается: то вечный индикатор, то белый экран, то ошибка поверх данных, которые уже были загружены минуту назад.
Первая загрузка
- Что видит пользователь
- Индикатор и подпись «Загружаем заказы…»
- Когда возникает
- Данных нет ни в памяти, ни в кэше
Данные
- Что видит пользователь
- Список
- Когда возникает
- Сервер ответил, ответ сохранён в кэш
Пусто
- Что видит пользователь
- «Заказов пока нет»
- Когда возникает
- Сервер вернул пустой массив
Ошибка
- Что видит пользователь
- Понятный текст и кнопка «Повторить»
- Когда возникает
- Нет сети или сервер ответил ошибкой, а кэша нет
Устаревшие данные
- Что видит пользователь
- Список и плашка «Показаны данные от 10:42»
- Когда возникает
- Сеть подвела, но в кэше есть прошлый ответ
Сам запрос пишется привычно: документация React Native прямо говорит, что для сети используется Fetch API. Отличий от браузера три. В нативном приложении нет CORS — сервер не нужно настраивать под «домен» приложения. iOS по умолчанию требует HTTPS (App Transport Security), а Android начиная с API 28 блокирует незашифрованный HTTP, поэтому http:// адрес тестового сервера просто не ответит. И наконец, всё, что попало в JavaScript-бандл, можно извлечь из установленного приложения.
Урок строится на одном экране «Мои заказы»: список с сервера, кэш на случай офлайна, настройки и токен авторизации. Проект — шаблон blank-typescript из create-expo-app. Пакеты ставим через npx expo install: эта команда выбирает версии, совместимые с текущим Expo SDK, поэтому для SDK 57 она установит AsyncStorage 2.2.0, а не последнюю 3.x из npm.
npx create-expo-app@latest orders-app --template blank-typescript
cd orders-app
npx expo install @react-native-async-storage/async-storage expo-secure-store
npx expo install @tanstack/react-query expo-networkКлиент API: fetch, статус ответа и отмена
Первое правило fetch, о котором забывают: промис отклоняется только при сетевой ошибке. Если сервер ответил 401, 404 или 500, fetch спокойно возвращает ответ, и MDN советует проверять response.ok самостоятельно. Поэтому все запросы проходят через одну функцию-обёртку: она подставляет токен, проверяет статус и превращает ошибку HTTP в исключение с кодом.
import { getToken } from '../storage/token';
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'https://api.example.com';
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
export async function apiGet<T>(path: string, signal?: AbortSignal): Promise<T> {
const token = await getToken();
const response = await fetch(`${API_URL}${path}`, {
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal,
});
if (!response.ok) {
// fetch не бросает исключение на 401/404/500 — проверяем статус сами
throw new ApiError(response.status, `HTTP ${response.status}`);
}
return (await response.json()) as T;
}import { apiGet } from './client';
export type OrderStatus = 'new' | 'in_progress' | 'done';
export type Order = {
id: string;
title: string;
status: OrderStatus;
updatedAt: string; // ISO 8601
};
export function fetchOrders(signal?: AbortSignal): Promise<Order[]> {
return apiGet<Order[]>('/v1/orders', signal);
}
const STATUS_LABEL: Record<OrderStatus, string> = {
new: 'Новый',
in_progress: 'В работе',
done: 'Готов',
};
export function formatStatus(status: OrderStatus): string {
return STATUS_LABEL[status];
}ApiErrorхранитstatus: экран отличит «сессия истекла» (401) от «сервер упал» (5xx) и от отсутствия сети (обычныйTypeErrorизfetch).signalизAbortControllerотменяет запрос, если пользователь ушёл с экрана или потянул список для обновления, пока старый запрос ещё шёл.- Токен читается из SecureStore при каждом запросе — после выхода из аккаунта старый токен не «залипнет» в памяти.
as T— только обещание компилятору, а не проверка. В продакшене ответ стоит проверять схемой (например, Zod): сервер может вернуть не тот формат после обновления.
Экран списка: FlatList и все состояния
Для списков в React Native используют FlatList: он отрисовывает только видимые строки, а не тысячу элементов сразу. Из коробки у него есть всё, что нужно нашему экрану: keyExtractor для стабильных ключей, ListEmptyComponent для пустого состояния и пара refreshing + onRefresh, которая добавляет стандартный жест «потяни, чтобы обновить».
import { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Button, FlatList, StyleSheet, Text, View } from 'react-native';
import { ApiError } from '../api/client';
import { fetchOrders, formatStatus, type Order } from '../api/orders';
import { readCache, writeCache } from '../storage/cache';
const CACHE_KEY = 'orders';
const MAX_AGE_MS = 5 * 60 * 1000; // кэш младше 5 минут считаем свежим
function errorText(e: unknown): string {
if (e instanceof ApiError && e.status === 401) return 'Сессия истекла — войдите снова';
if (e instanceof ApiError) return `Сервер ответил ошибкой ${e.status}`;
return 'Нет соединения с сервером';
}
function timeOf(ts: number): string {
return new Date(ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}
export default function OrdersScreen() {
const [orders, setOrders] = useState<Order[] | null>(null); // null — показать пока нечего
const [status, setStatus] = useState<'loading' | 'refreshing' | 'idle'>('loading');
const [error, setError] = useState<string | null>(null);
const [cachedAt, setCachedAt] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback(async (mode: 'initial' | 'refresh') => {
abortRef.current?.abort(); // отменяем предыдущий запрос, если он ещё идёт
const controller = new AbortController();
abortRef.current = controller;
setStatus(mode === 'refresh' ? 'refreshing' : 'loading');
setError(null);
if (mode === 'initial') {
const hit = await readCache<Order[]>(CACHE_KEY, MAX_AGE_MS);
if (controller.signal.aborted) return;
if (hit) {
setOrders(hit.data); // сначала показываем сохранённое — экран не пустой
setCachedAt(hit.savedAt);
if (!hit.stale) {
setStatus('idle');
return;
}
}
}
try {
const fresh = await fetchOrders(controller.signal);
setOrders(fresh);
setCachedAt(null);
await writeCache(CACHE_KEY, fresh);
} catch (e) {
if (!controller.signal.aborted) setError(errorText(e));
} finally {
if (!controller.signal.aborted) setStatus('idle');
}
}, []);
useEffect(() => {
load('initial');
return () => abortRef.current?.abort();
}, [load]);
if (orders === null && status === 'loading') {
return (
<View style={styles.center}>
<ActivityIndicator size="large" />
<Text style={styles.muted}>Загружаем заказы…</Text>
</View>
);
}
if (orders === null && error) {
return (
<View style={styles.center}>
<Text style={styles.error}>{error}</Text>
<Button title="Повторить" onPress={() => load('initial')} />
</View>
);
}
return (
<FlatList
data={orders ?? []}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.row}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.muted}>{formatStatus(item.status)}</Text>
</View>
)}
ListHeaderComponent={
error && cachedAt ? (
<Text style={styles.banner}>
{error}. Показаны данные от {timeOf(cachedAt)}
</Text>
) : null
}
ListEmptyComponent={<Text style={styles.muted}>Заказов пока нет</Text>}
refreshing={status === 'refreshing'}
onRefresh={() => load('refresh')}
/>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12, padding: 24 },
row: { paddingVertical: 12, paddingHorizontal: 16, borderBottomWidth: StyleSheet.hairlineWidth },
title: { fontSize: 16, fontWeight: '600' },
muted: { color: '#666', padding: 16 },
error: { color: '#b00020', textAlign: 'center' },
banner: { backgroundColor: '#fff4ce', padding: 12 },
});Отменить старый запрос
Если пользователь дёргает обновление дважды, первый ответ не должен перезаписать второй.
Показать кэш
При первом открытии экран сразу рисует сохранённый список — без пустого экрана и индикатора.
Решить, нужна ли сеть
Кэш младше пяти минут считается свежим, и запрос не отправляется. Жест обновления всегда идёт в сеть.
Загрузить и сохранить
Свежий ответ заменяет список и записывается в кэш вместе со временем.
Пережить ошибку
Если данные уже на экране, ошибка показывается плашкой над списком, а не вместо него.
AsyncStorage: настройки и кэш
AsyncStorage — асинхронное, незашифрованное, постоянное хранилище «ключ — значение». Значения — только строки, поэтому объекты сохраняют через JSON.stringify и читают через JSON.parse. На Android данные лежат в SQLite, на iOS — в файлах внутри песочницы приложения. Документация v2 называет ограничения Android: по умолчанию около 6 МБ на всё хранилище и около 2 МБ на одну запись.
import AsyncStorage from '@react-native-async-storage/async-storage';
export type Settings = {
theme: 'system' | 'light' | 'dark';
showDone: boolean;
};
export const DEFAULT_SETTINGS: Settings = { theme: 'system', showDone: true };
const SETTINGS_KEY = 'settings:v1';
export async function loadSettings(): Promise<Settings> {
try {
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
if (raw === null) return DEFAULT_SETTINGS;
// старая версия приложения могла сохранить не все поля — дополняем умолчаниями
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<Settings>) };
} catch {
return DEFAULT_SETTINGS; // повреждённый JSON не должен ронять приложение
}
}
export async function saveSettings(patch: Partial<Settings>): Promise<Settings> {
const next = { ...(await loadSettings()), ...patch };
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(next));
return next;
}import AsyncStorage from '@react-native-async-storage/async-storage';
type Entry<T> = { savedAt: number; data: T };
export type CacheHit<T> = { data: T; savedAt: number; stale: boolean };
export async function writeCache<T>(key: string, data: T): Promise<void> {
const entry: Entry<T> = { savedAt: Date.now(), data };
await AsyncStorage.setItem(`cache:${key}`, JSON.stringify(entry));
}
export async function readCache<T>(key: string, maxAgeMs: number): Promise<CacheHit<T> | null> {
try {
const raw = await AsyncStorage.getItem(`cache:${key}`);
if (raw === null) return null;
const entry = JSON.parse(raw) as Entry<T>;
return { ...entry, stale: Date.now() - entry.savedAt > maxAgeMs };
} catch {
return null;
}
}Обратите внимание на суффикс :v1 в ключе настроек и на слияние с DEFAULT_SETTINGS: приложение обновляется, а данные на телефоне остаются от прошлой версии. Новое поле должно получить значение по умолчанию, а сломанный JSON — не ронять приложение при старте.
SecureStore: токены и секреты
expo-secure-store шифрует пары «ключ — значение»: на Android значения хранятся в SharedPreferences, зашифрованные ключом из Android Keystore, на iOS — в Keychain. Он предназначен для небольших значений: токенов, ключей, секретов. Ключ может содержать только буквы, цифры, точку, дефис и подчёркивание. Веб SecureStore не поддерживает: в документации указаны только Android, iOS и tvOS, поэтому для веб-версии приложения токен хранят иначе — обычно в httpOnly-cookie, которую выставляет сервер.
import * as SecureStore from 'expo-secure-store';
// ключ: только буквы, цифры, «.», «-» и «_»
const TOKEN_KEY = 'auth.accessToken';
export function saveToken(token: string): Promise<void> {
return SecureStore.setItemAsync(TOKEN_KEY, token);
}
export function getToken(): Promise<string | null> {
return SecureStore.getItemAsync(TOKEN_KEY);
}
export function clearToken(): Promise<void> {
return SecureStore.deleteItemAsync(TOKEN_KEY);
}AsyncStorage
- Не шифрует данные
- Настройки, кэш ответов, черновики, флаг «онбординг пройден»
- Работает на Android, iOS и в вебе
- Подходит для объёмов в сотни килобайт
SecureStore
- Шифрует через Keychain и Android Keystore
- Access и refresh-токены, ключ шифрования локальной базы
- Android, iOS и tvOS, без веба
- Только маленькие значения: исторически iOS отклоняла записи больше ~2048 байт
- На iOS запись в Keychain переживает удаление приложения, если его установить снова с тем же bundle ID. Документация Expo просит на это не полагаться, но и не удивляться «воскресшему» токену — при первом запуске его стоит проверять на сервере.
- На Android данные SecureStore при удалении приложения пропадают. Автоматическую резервную копию Android config plugin
expo-secure-storeпо умолчанию настраивает так, чтобы зашифрованные записи туда не попадали: восстановить их всё равно нельзя. - SecureStore — не место для единственной копии важных данных: сервер остаётся источником правды.
TanStack Query и SQLite: когда ручного кода мало
Ручной экран из урока полезно написать один раз, чтобы понимать механику. Когда экранов с запросами становится десяток, повторять в каждом отмену, кэш, повторы и обновление при возвращении в приложение — дорого. TanStack Query, по документации, работает в React Native без доработок, но двух вещей не знает: когда пропала сеть и когда приложение вернулось из фона. Их подключают через onlineManager и focusManager.
import { AppState, Platform, type AppStateStatus } from 'react-native';
import * as Network from 'expo-network';
import { focusManager, onlineManager, QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 2 } },
});
// сеть пропала/вернулась → TanStack Query приостанавливает и возобновляет запросы
onlineManager.setEventListener((setOnline) => {
const subscription = Network.addNetworkStateListener((state) => {
setOnline(!!state.isConnected);
});
return subscription.remove;
});
// приложение вернулось из фона → устаревшие запросы обновятся
export function onAppStateChange(status: AppStateStatus) {
if (Platform.OS !== 'web') focusManager.setFocused(status === 'active');
}
export function subscribeAppState() {
const subscription = AppState.addEventListener('change', onAppStateChange);
return () => subscription.remove();
}import { useQuery } from '@tanstack/react-query';
import { ActivityIndicator, Button, FlatList, Text, View } from 'react-native';
import { fetchOrders, formatStatus } from '../api/orders';
export default function OrdersQueryScreen() {
const { data, error, isPending, isRefetching, refetch } = useQuery({
queryKey: ['orders'],
queryFn: ({ signal }) => fetchOrders(signal), // signal отменит запрос при уходе с экрана
staleTime: 5 * 60 * 1000,
});
if (isPending) return <ActivityIndicator size="large" />;
if (error && !data) {
return (
<View>
<Text>Не удалось загрузить заказы</Text>
<Button title="Повторить" onPress={() => refetch()} />
</View>
);
}
return (
<FlatList
data={data}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Text>
{item.title} — {formatStatus(item.status)}
</Text>
)}
ListEmptyComponent={<Text>Заказов пока нет</Text>}
refreshing={isRefetching}
onRefresh={() => refetch()}
/>
);
}В корневом App.tsx экран оборачивают в QueryClientProvider client={queryClient} и один раз вызывают subscribeAppState() в эффекте. Кэш TanStack Query живёт в памяти: после перезапуска приложения он пуст. Для сохранения между запусками у библиотеки есть плагин createAsyncStoragePersister; в этом уроке он не проверялся.
Тема, язык, флаги онбординга
- Чем
- AsyncStorage
- Почему
- Мало данных, шифрование не нужно
Последний ответ списка для офлайна
- Чем
- AsyncStorage или кэш TanStack Query
- Почему
- Небольшой JSON с отметкой времени
Access и refresh-токены
- Чем
- expo-secure-store
- Почему
- Секрет, нужен шифрованный Keychain/Keystore
Тысячи записей, поиск, фильтры, связи
- Чем
- expo-sqlite
- Почему
- Нужны запросы и индексы, а не чтение всего JSON целиком
Фото, PDF, загруженные файлы
- Чем
- expo-file-system
- Почему
- Бинарные данные не кладут в строковые хранилища
По нашему опыту, переход на SQLite оправдан, когда приложение должно полноценно работать офлайн: создавать записи без сети и отправлять их позже. Тогда нужен не кэш ответа, а локальная база с очередью изменений — это отдельная архитектурная задача.
Проверка: типы, тесты и сборка бандла
Логику хранения и все состояния экрана можно проверить без телефона. Expo рекомендует пресет jest-expo, который подменяет нативную часть Expo SDK, и React Native Testing Library для компонентов. AsyncStorage поставляет собственный мок в памяти, а SecureStore в тестах заменяем словарём Map.
npx expo install jest-expo jest @types/jest @testing-library/react-native --dev--dev — флаг Expo CLI, пакеты попадут в devDependencies{
"scripts": {
"test": "jest"
},
"jest": {
"preset": "jest-expo",
"transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)"
]
}
}module.exports = require('@react-native-async-storage/async-storage/jest/async-storage-mock');import AsyncStorage from '@react-native-async-storage/async-storage';
import { DEFAULT_SETTINGS, loadSettings, saveSettings } from '../src/storage/settings';
import { readCache, writeCache } from '../src/storage/cache';
import { clearToken, getToken, saveToken } from '../src/storage/token';
// SecureStore — нативный модуль: в тестах подменяем его хранилищем в памяти
jest.mock('expo-secure-store', () => {
const store = new Map<string, string>();
return {
setItemAsync: jest.fn(async (k: string, v: string) => void store.set(k, v)),
getItemAsync: jest.fn(async (k: string) => store.get(k) ?? null),
deleteItemAsync: jest.fn(async (k: string) => void store.delete(k)),
};
});
beforeEach(async () => {
await AsyncStorage.clear();
});
test('настройки: по умолчанию, слияние и повреждённый JSON', async () => {
expect(await loadSettings()).toEqual(DEFAULT_SETTINGS);
await saveSettings({ theme: 'dark' });
expect(await loadSettings()).toEqual({ theme: 'dark', showDone: true });
await AsyncStorage.setItem('settings:v1', '{oops');
expect(await loadSettings()).toEqual(DEFAULT_SETTINGS);
});
test('кэш становится устаревшим по времени', async () => {
const now = jest.spyOn(Date, 'now').mockReturnValue(1_000_000);
await writeCache('orders', [{ id: '1' }]);
expect(await readCache('orders', 60_000)).toMatchObject({ stale: false, data: [{ id: '1' }] });
now.mockReturnValue(1_000_000 + 61_000);
expect((await readCache('orders', 60_000))?.stale).toBe(true);
expect(await readCache('missing', 60_000)).toBeNull();
now.mockRestore();
});
test('токен живёт в SecureStore, а не в AsyncStorage', async () => {
await saveToken('test-token');
expect(await getToken()).toBe('test-token');
expect(await AsyncStorage.getAllKeys()).toEqual([]);
await clearToken();
expect(await getToken()).toBeNull();
});import AsyncStorage from '@react-native-async-storage/async-storage';
import { fireEvent, render, screen } from '@testing-library/react-native';
import OrdersScreen from '../src/screens/OrdersScreen';
import { writeCache } from '../src/storage/cache';
jest.mock('expo-secure-store', () => ({ getItemAsync: jest.fn(async () => null) }));
const orders = [
{ id: '1', title: 'Заказ №1', status: 'new', updatedAt: '2026-10-11T09:00:00Z' },
{ id: '2', title: 'Заказ №2', status: 'done', updatedAt: '2026-10-11T10:00:00Z' },
];
function mockFetchOnce(body: unknown, status = 200) {
(globalThis.fetch as jest.Mock).mockResolvedValueOnce({
ok: status >= 200 && status < 300,
status,
json: async () => body,
});
}
beforeEach(async () => {
await AsyncStorage.clear();
globalThis.fetch = jest.fn();
});
test('загрузка → список, ответ кэшируется', async () => {
let respond!: (value: unknown) => void;
(globalThis.fetch as jest.Mock).mockReturnValueOnce(new Promise((r) => (respond = r)));
await render(<OrdersScreen />);
expect(screen.getByText('Загружаем заказы…')).toBeOnTheScreen(); // ответа ещё нет
respond({ ok: true, status: 200, json: async () => orders });
expect(await screen.findByText('Заказ №1')).toBeOnTheScreen();
expect(screen.getByText('Готов')).toBeOnTheScreen();
expect(await AsyncStorage.getItem('cache:orders')).toContain('Заказ №2');
});
test('пустой ответ → пустое состояние', async () => {
mockFetchOnce([]);
await render(<OrdersScreen />);
expect(await screen.findByText('Заказов пока нет')).toBeOnTheScreen();
});
test('ошибка без кэша → сообщение и «Повторить»', async () => {
(globalThis.fetch as jest.Mock).mockRejectedValueOnce(new TypeError('Network request failed'));
mockFetchOnce(orders);
await render(<OrdersScreen />);
expect(await screen.findByText('Нет соединения с сервером')).toBeOnTheScreen();
await fireEvent.press(screen.getByText('Повторить'));
expect(await screen.findByText('Заказ №1')).toBeOnTheScreen();
});
test('HTTP 500 при устаревшем кэше → данные из кэша и плашка', async () => {
const now = jest.spyOn(Date, 'now').mockReturnValue(Date.UTC(2026, 9, 11, 8, 0));
await writeCache('orders', orders);
now.mockReturnValue(Date.UTC(2026, 9, 11, 9, 0)); // прошёл час — кэш устарел
mockFetchOnce({ message: 'boom' }, 500);
await render(<OrdersScreen />);
expect(await screen.findByText(/Сервер ответил ошибкой 500/)).toBeOnTheScreen();
expect(screen.getByText('Заказ №1')).toBeOnTheScreen();
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
now.mockRestore();
});
test('свежий кэш → сеть не трогаем', async () => {
await writeCache('orders', orders);
await render(<OrdersScreen />);
expect(await screen.findByText('Заказ №2')).toBeOnTheScreen();
expect(globalThis.fetch).not.toHaveBeenCalled();
});render и fireEvent асинхронные — их нужно ждать через await$ npx tsc --noEmit
$ npx jest --verbose
PASS __tests__/storage.test.ts
√ настройки: по умолчанию, слияние и повреждённый JSON (10 ms)
√ кэш становится устаревшим по времени (1 ms)
√ токен живёт в SecureStore, а не в AsyncStorage (1 ms)
PASS __tests__/OrdersScreen.test.tsx
√ загрузка → список, ответ кэшируется (913 ms)
√ пустой ответ → пустое состояние (18 ms)
√ ошибка без кэша → сообщение и «Повторить» (93 ms)
√ HTTP 500 при устаревшем кэше → данные из кэша и плашка (62 ms)
√ свежий кэш → сеть не трогаем (8 ms)
Tests: 8 passed, 8 total
$ npx expo export --platform android --platform ios
Android Bundled 13969ms index.ts (644 modules)
iOS Bundled 17830ms index.ts (646 modules)
Exported: dist
$ npx expo-doctor
21/21 checks passed. No issues detected!expo export собирает JavaScript-бандлы для Android и iOS без устройства — так ловятся ошибки импорта и несовместимые пакеты. Запуск на телефоне, реальная сеть и поведение Keychain/Keystore этой проверкой не покрываются: их смотрят в Expo Go или development build на устройстве.
Как проверено4 · пройдено1 · частично1 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
src/api/client.ts, src/api/orders.ts, src/storage/{settings,cache,token}.ts
временный проект create-expo-app (blank-typescript), Expo SDK 57.0.27 / RN 0.86.3 / React 19.2.3; tsc --noEmit (TypeScript 6.0.3); jest-expo 57.0.5 — __tests__/storage.test.ts (3 теста) с моком AsyncStorage 2.2.0 и SecureStore-моком на Map
- запущено, работает
src/screens/OrdersScreen.tsx
@testing-library/react-native 14.1.0: 5 тестов с мок-fetch (загрузка→список+кэш, пусто, ошибка+Повторить, HTTP 500 при устаревшем кэше, свежий кэш без сети)
- запущено, работает
решения упражнений visibleOrders.ts, signOutOn401.ts
__tests__/exercises.test.ts (2 теста), tsc --noEmit
- запущено, работает
сборка и окружение
npx expo export --platform android --platform ios — OK; npx expo-doctor@1.20.4 — 21/21 checks passed; команды установки (create-expo-app 5.0.0, expo install, --dev) выполнены
- частично
src/query.ts, src/screens/OrdersQueryScreen.tsx, App.tsx
tsc --noEmit + npx expo export --platform android --platform ios (бандлы Hermes собраны); поведение TanStack Query 5.104.2 и expo-network 57.0.2 тестами не покрыто
- не запускалось
запуск на устройстве (Expo Go / development build), реальная сеть, Keychain/Keystore
устройства и эмуляторы на машине недоступны; поведение SecureStore на iOS/Android — по документации Expo
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.