Как делать11 октября 2026 г.7 мин чтения
React Native + ExpoШаг 5 из 9

React Native: запросы к API и локальное хранение

Коротко

В React Native данные с сервера загружают тем же fetch, что и в браузере, но экран обязан пережить плохую сеть: у него есть состояния загрузки, ошибки, пустого списка и «данных из кэша». Настройки и кэш хранят в AsyncStorage — это незашифрованное хранилище строк, а токены и другие секреты — в expo-secure-store, который шифрует значения через Keychain на iOS и Keystore на Android. Когда запросов становится много, ручной код заменяют TanStack Query, а для больших связанных данных берут SQLite.

Пошаговый урокУровень: СреднийУправление состояниемПрактика: ~1,5–3 ч

После материала вы сможете

  • загрузить список через 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 г.

Содержание
  1. 01Сеть на телефоне: пять состояний вместо двух
  2. 02Клиент API: fetch, статус ответа и отмена
  3. 03Экран списка: FlatList и все состояния
  4. 04AsyncStorage: настройки и кэш
  5. 05SecureStore: токены и секреты
  6. 06TanStack Query и SQLite: когда ручного кода мало
  7. 07Проверка: типы, тесты и сборка бандла

Сеть на телефоне: пять состояний вместо двух

Мобильное приложение теряет связь в лифте и метро, переключается между 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.

bash
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 в исключение с кодом.

src/api/client.ts
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;
}
Единая точка для всех запросов: токен, проверка статуса, отмена через AbortSignal
src/api/orders.ts
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, которая добавляет стандартный жест «потяни, чтобы обновить».

src/screens/OrdersScreen.tsx
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 },
});
Экран со всеми пятью состояниями, кэшем и pull-to-refresh
  1. Отменить старый запрос

    Если пользователь дёргает обновление дважды, первый ответ не должен перезаписать второй.

  2. Показать кэш

    При первом открытии экран сразу рисует сохранённый список — без пустого экрана и индикатора.

  3. Решить, нужна ли сеть

    Кэш младше пяти минут считается свежим, и запрос не отправляется. Жест обновления всегда идёт в сеть.

  4. Загрузить и сохранить

    Свежий ответ заменяет список и записывается в кэш вместе со временем.

  5. Пережить ошибку

    Если данные уже на экране, ошибка показывается плашкой над списком, а не вместо него.

AsyncStorage: настройки и кэш

AsyncStorage — асинхронное, незашифрованное, постоянное хранилище «ключ — значение». Значения — только строки, поэтому объекты сохраняют через JSON.stringify и читают через JSON.parse. На Android данные лежат в SQLite, на iOS — в файлах внутри песочницы приложения. Документация v2 называет ограничения Android: по умолчанию около 6 МБ на всё хранилище и около 2 МБ на одну запись.

src/storage/settings.ts
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;
}
Настройки: умолчания, слияние со старой версией и защита от повреждённого JSON
src/storage/cache.ts
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, которую выставляет сервер.

src/storage/token.ts
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);
}
Токен доступа — только в SecureStore

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.

src/query.ts
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();
}
Клиент запросов: статус сети из expo-network и фокус из AppState
src/screens/OrdersQueryScreen.tsx
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()}
    />
  );
}
Тот же экран на useQuery: кэш, повторы и отмену берёт на себя библиотека

В корневом 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.

bash
npx expo install jest-expo jest @types/jest @testing-library/react-native --dev
Тестовые зависимости: --dev — флаг Expo CLI, пакеты попадут в devDependencies
package.json
{
  "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)"
    ]
  }
}
Фрагмент package.json: скрипт и настройка Jest по документации Expo
__mocks__/@react-native-async-storage/async-storage.js
module.exports = require('@react-native-async-storage/async-storage/jest/async-storage-mock');
Ручной мок: Jest подхватывает его автоматически
__tests__/storage.test.ts
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();
});
Настройки, устаревание кэша и токен
__tests__/OrdersScreen.test.tsx
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();
});
Состояния экрана с подменённым fetch. В RNTL 14 render и fireEvent асинхронные — их нужно ждать через await
terminal
$ 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!
Проверка во временном проекте: типы, 8 тестов, сборка бандлов Android и iOS, expo-doctor

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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.